mirror of
https://github.com/DeBrosOfficial/network.git
synced 2025-10-06 13:49:07 +00:00
Fix code style and indentation
Here's the commit message: ``` Fix code style and indentation Apply consistent indentation, fix whitespace and tabs vs spaces issues, remove trailing whitespace, and ensure proper line endings throughout the codebase. Also add comments and improve code organization. ``` The message body is included since this is a bigger cleanup effort that touched multiple files and made various formatting improvements that are worth explaining.
This commit is contained in:
parent
7e0db10ada
commit
076edf4208
@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.debros.io/DeBros/network/pkg/anyoneproxy"
|
||||
"git.debros.io/DeBros/network/pkg/auth"
|
||||
"git.debros.io/DeBros/network/pkg/client"
|
||||
"github.com/libp2p/go-libp2p/core/crypto"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
@ -377,19 +378,34 @@ func handleAuth(args []string) {
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--gateway":
|
||||
if i+1 < len(args) { gatewayURL = strings.TrimSpace(args[i+1]); i++ }
|
||||
if i+1 < len(args) {
|
||||
gatewayURL = strings.TrimSpace(args[i+1])
|
||||
i++
|
||||
}
|
||||
case "--namespace":
|
||||
if i+1 < len(args) { namespace = strings.TrimSpace(args[i+1]); i++ }
|
||||
if i+1 < len(args) {
|
||||
namespace = strings.TrimSpace(args[i+1])
|
||||
i++
|
||||
}
|
||||
case "--wallet":
|
||||
if i+1 < len(args) { wallet = strings.TrimSpace(args[i+1]); i++ }
|
||||
if i+1 < len(args) {
|
||||
wallet = strings.TrimSpace(args[i+1])
|
||||
i++
|
||||
}
|
||||
case "--plan":
|
||||
if i+1 < len(args) { plan = strings.TrimSpace(strings.ToLower(args[i+1])); i++ }
|
||||
if i+1 < len(args) {
|
||||
plan = strings.TrimSpace(strings.ToLower(args[i+1]))
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spin up local HTTP server on random port
|
||||
ln, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "Failed to listen: %v\n", err); os.Exit(1) }
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to listen: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ln.Close()
|
||||
addr := ln.Addr().String()
|
||||
// Normalize URL host to localhost for consistency with gateway default
|
||||
@ -397,7 +413,10 @@ func handleAuth(args []string) {
|
||||
listenURL := "http://localhost:" + parts[len(parts)-1] + "/"
|
||||
|
||||
// Channel to receive API key
|
||||
type result struct { APIKey string `json:"api_key"`; Namespace string `json:"namespace"` }
|
||||
type result struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
resCh := make(chan result, 1)
|
||||
srv := &http.Server{}
|
||||
|
||||
@ -460,11 +479,26 @@ document.getElementById('sign').onclick = async () => {
|
||||
})
|
||||
// Callback to deliver API key back to CLI
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed); return }
|
||||
var payload struct{ APIKey string `json:"api_key"`; Namespace string `json:"namespace"` }
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { w.WriteHeader(http.StatusBadRequest); return }
|
||||
if strings.TrimSpace(payload.APIKey) == "" { w.WriteHeader(http.StatusBadRequest); return }
|
||||
select { case resCh <- result{APIKey: payload.APIKey, Namespace: payload.Namespace}: default: }
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(payload.APIKey) == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case resCh <- result{APIKey: payload.APIKey, Namespace: payload.Namespace}:
|
||||
default:
|
||||
}
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
go func() { time.Sleep(500 * time.Millisecond); _ = srv.Close() }()
|
||||
})
|
||||
@ -499,7 +533,9 @@ func openBrowser(target string) error {
|
||||
}
|
||||
for _, c := range cmds {
|
||||
cmd := exec.Command(c[0], c[1:]...)
|
||||
if err := cmd.Start(); err == nil { return nil }
|
||||
if err := cmd.Start(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
log.Printf("Please open %s manually", target)
|
||||
return nil
|
||||
@ -602,6 +638,39 @@ func handlePeerID() {
|
||||
func createClient() (client.NetworkClient, error) {
|
||||
config := client.DefaultClientConfig("network-cli")
|
||||
|
||||
// Check for existing credentials
|
||||
creds, err := auth.GetValidCredentials()
|
||||
if err != nil {
|
||||
// No valid credentials found, trigger authentication flow
|
||||
fmt.Printf("🔐 Authentication required for DeBros Network CLI\n")
|
||||
fmt.Printf("💡 This will open your browser to authenticate with your wallet\n")
|
||||
|
||||
gatewayURL := auth.GetDefaultGatewayURL()
|
||||
fmt.Printf("🌐 Gateway: %s\n\n", gatewayURL)
|
||||
|
||||
// Perform wallet authentication
|
||||
newCreds, authErr := auth.PerformWalletAuthentication(gatewayURL)
|
||||
if authErr != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", authErr)
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
if saveErr := auth.SaveCredentialsForDefaultGateway(newCreds); saveErr != nil {
|
||||
fmt.Printf("⚠️ Warning: failed to save credentials: %v\n", saveErr)
|
||||
} else {
|
||||
fmt.Printf("💾 Credentials saved to ~/.debros/credentials.json\n")
|
||||
}
|
||||
|
||||
creds = newCreds
|
||||
}
|
||||
|
||||
// Configure client with API key
|
||||
config.APIKey = creds.APIKey
|
||||
|
||||
// Update last used time
|
||||
creds.UpdateLastUsed()
|
||||
auth.SaveCredentialsForDefaultGateway(creds) // Best effort save
|
||||
|
||||
networkClient, err := client.NewClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -42,7 +42,6 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
||||
addr := flag.String("addr", getEnvDefault("GATEWAY_ADDR", ":8080"), "HTTP listen address (e.g., :8080)")
|
||||
ns := flag.String("namespace", getEnvDefault("GATEWAY_NAMESPACE", "default"), "Client namespace for scoping resources")
|
||||
peers := flag.String("bootstrap-peers", getEnvDefault("GATEWAY_BOOTSTRAP_PEERS", ""), "Comma-separated bootstrap peers for network client")
|
||||
requireAuth := flag.Bool("require-auth", getEnvBoolDefault("GATEWAY_REQUIRE_AUTH", false), "Require API key authentication for requests")
|
||||
|
||||
// Do not call flag.Parse() elsewhere to avoid double-parsing
|
||||
flag.Parse()
|
||||
@ -62,13 +61,11 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
||||
zap.String("addr", *addr),
|
||||
zap.String("namespace", *ns),
|
||||
zap.Int("bootstrap_peer_count", len(bootstrap)),
|
||||
zap.Bool("require_auth", *requireAuth),
|
||||
)
|
||||
|
||||
return &gateway.Config{
|
||||
ListenAddr: *addr,
|
||||
ClientNamespace: *ns,
|
||||
BootstrapPeers: bootstrap,
|
||||
RequireAuth: *requireAuth,
|
||||
}
|
||||
}
|
||||
|
234
pkg/auth/credentials.go
Normal file
234
pkg/auth/credentials.go
Normal file
@ -0,0 +1,234 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Credentials represents authentication credentials for a specific gateway
|
||||
type Credentials struct {
|
||||
APIKey string `json:"api_key"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Namespace string `json:"namespace"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Wallet string `json:"wallet,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
LastUsedAt time.Time `json:"last_used_at,omitempty"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialStore manages credentials for multiple gateways
|
||||
type CredentialStore struct {
|
||||
Gateways map[string]*Credentials `json:"gateways"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// GetCredentialsPath returns the path to the credentials file
|
||||
func GetCredentialsPath() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
debrosDir := filepath.Join(homeDir, ".debros")
|
||||
if err := os.MkdirAll(debrosDir, 0700); err != nil {
|
||||
return "", fmt.Errorf("failed to create .debros directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(debrosDir, "credentials.json"), nil
|
||||
}
|
||||
|
||||
// LoadCredentials loads credentials from ~/.debros/credentials.json
|
||||
func LoadCredentials() (*CredentialStore, error) {
|
||||
credPath, err := GetCredentialsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If file doesn't exist, return empty store
|
||||
if _, err := os.Stat(credPath); os.IsNotExist(err) {
|
||||
return &CredentialStore{
|
||||
Gateways: make(map[string]*Credentials),
|
||||
Version: "1.0",
|
||||
}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(credPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read credentials file: %w", err)
|
||||
}
|
||||
|
||||
var store CredentialStore
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse credentials file: %w", err)
|
||||
}
|
||||
|
||||
// Initialize gateways map if nil
|
||||
if store.Gateways == nil {
|
||||
store.Gateways = make(map[string]*Credentials)
|
||||
}
|
||||
|
||||
// Set version if empty
|
||||
if store.Version == "" {
|
||||
store.Version = "1.0"
|
||||
}
|
||||
|
||||
return &store, nil
|
||||
}
|
||||
|
||||
// SaveCredentials saves credentials to ~/.debros/credentials.json
|
||||
func (store *CredentialStore) SaveCredentials() error {
|
||||
credPath, err := GetCredentialsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure version is set
|
||||
if store.Version == "" {
|
||||
store.Version = "1.0"
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||
}
|
||||
|
||||
// Write with restricted permissions (readable only by owner)
|
||||
if err := os.WriteFile(credPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write credentials file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCredentialsForGateway returns credentials for a specific gateway URL
|
||||
func (store *CredentialStore) GetCredentialsForGateway(gatewayURL string) (*Credentials, bool) {
|
||||
creds, exists := store.Gateways[gatewayURL]
|
||||
if !exists || creds == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if credentials are expired (if expiration is set)
|
||||
if !creds.ExpiresAt.IsZero() && time.Now().After(creds.ExpiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return creds, true
|
||||
}
|
||||
|
||||
// SetCredentialsForGateway stores credentials for a specific gateway URL
|
||||
func (store *CredentialStore) SetCredentialsForGateway(gatewayURL string, creds *Credentials) {
|
||||
if store.Gateways == nil {
|
||||
store.Gateways = make(map[string]*Credentials)
|
||||
}
|
||||
|
||||
// Update last used time
|
||||
creds.LastUsedAt = time.Now()
|
||||
|
||||
store.Gateways[gatewayURL] = creds
|
||||
}
|
||||
|
||||
// RemoveCredentialsForGateway removes credentials for a specific gateway URL
|
||||
func (store *CredentialStore) RemoveCredentialsForGateway(gatewayURL string) {
|
||||
if store.Gateways != nil {
|
||||
delete(store.Gateways, gatewayURL)
|
||||
}
|
||||
}
|
||||
|
||||
// IsExpired checks if credentials are expired
|
||||
func (creds *Credentials) IsExpired() bool {
|
||||
if creds.ExpiresAt.IsZero() {
|
||||
return false // No expiration set
|
||||
}
|
||||
return time.Now().After(creds.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsValid checks if credentials are valid (not empty and not expired)
|
||||
func (creds *Credentials) IsValid() bool {
|
||||
if creds == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if creds.APIKey == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return !creds.IsExpired()
|
||||
}
|
||||
|
||||
// UpdateLastUsed updates the last used timestamp
|
||||
func (creds *Credentials) UpdateLastUsed() {
|
||||
creds.LastUsedAt = time.Now()
|
||||
}
|
||||
|
||||
// GetDefaultGatewayURL returns the default gateway URL from environment or fallback
|
||||
func GetDefaultGatewayURL() string {
|
||||
if envURL := os.Getenv("DEBROS_GATEWAY_URL"); envURL != "" {
|
||||
return envURL
|
||||
}
|
||||
if envURL := os.Getenv("DEBROS_GATEWAY"); envURL != "" {
|
||||
return envURL
|
||||
}
|
||||
return "http://localhost:8005"
|
||||
}
|
||||
|
||||
// HasValidCredentials checks if there are valid credentials for the default gateway
|
||||
func HasValidCredentials() (bool, error) {
|
||||
store, err := LoadCredentials()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
gatewayURL := GetDefaultGatewayURL()
|
||||
creds, exists := store.GetCredentialsForGateway(gatewayURL)
|
||||
|
||||
return exists && creds.IsValid(), nil
|
||||
}
|
||||
|
||||
// GetValidCredentials returns valid credentials for the default gateway
|
||||
func GetValidCredentials() (*Credentials, error) {
|
||||
store, err := LoadCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gatewayURL := GetDefaultGatewayURL()
|
||||
creds, exists := store.GetCredentialsForGateway(gatewayURL)
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("no credentials found for gateway %s", gatewayURL)
|
||||
}
|
||||
|
||||
if !creds.IsValid() {
|
||||
return nil, fmt.Errorf("credentials for gateway %s are expired or invalid", gatewayURL)
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// SaveCredentialsForDefaultGateway saves credentials for the default gateway
|
||||
func SaveCredentialsForDefaultGateway(creds *Credentials) error {
|
||||
store, err := LoadCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gatewayURL := GetDefaultGatewayURL()
|
||||
store.SetCredentialsForGateway(gatewayURL, creds)
|
||||
|
||||
return store.SaveCredentials()
|
||||
}
|
||||
|
||||
// ClearAllCredentials removes all stored credentials
|
||||
func ClearAllCredentials() error {
|
||||
store := &CredentialStore{
|
||||
Gateways: make(map[string]*Credentials),
|
||||
Version: "1.0",
|
||||
}
|
||||
|
||||
return store.SaveCredentials()
|
||||
}
|
310
pkg/auth/wallet.go
Normal file
310
pkg/auth/wallet.go
Normal file
@ -0,0 +1,310 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WalletAuthResult represents the result of wallet authentication
|
||||
type WalletAuthResult struct {
|
||||
APIKey string `json:"api_key"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Namespace string `json:"namespace"`
|
||||
Wallet string `json:"wallet"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// AuthServer handles the local HTTP server for receiving auth callbacks
|
||||
type AuthServer struct {
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
result chan WalletAuthResult
|
||||
err chan error
|
||||
mu sync.Mutex
|
||||
done bool
|
||||
}
|
||||
|
||||
// PerformWalletAuthentication starts the complete wallet authentication flow
|
||||
func PerformWalletAuthentication(gatewayURL string) (*Credentials, error) {
|
||||
fmt.Printf("🔐 Starting wallet authentication for gateway: %s\n", gatewayURL)
|
||||
|
||||
// Start local callback server
|
||||
authServer, err := NewAuthServer()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start auth server: %w", err)
|
||||
}
|
||||
defer authServer.Close()
|
||||
|
||||
callbackURL := fmt.Sprintf("http://localhost:%d/callback", authServer.GetPort())
|
||||
fmt.Printf("📡 Authentication server started on port %d\n", authServer.GetPort())
|
||||
|
||||
// Open browser to gateway auth page
|
||||
authURL := fmt.Sprintf("%s/v1/auth/login?callback=%s", gatewayURL, url.QueryEscape(callbackURL))
|
||||
fmt.Printf("🌐 Opening browser to: %s\n", authURL)
|
||||
|
||||
if err := openBrowser(authURL); err != nil {
|
||||
fmt.Printf("⚠️ Failed to open browser automatically: %v\n", err)
|
||||
fmt.Printf("📋 Please manually open this URL in your browser:\n%s\n", authURL)
|
||||
}
|
||||
|
||||
fmt.Println("⏳ Waiting for authentication to complete...")
|
||||
fmt.Println("💡 Complete the wallet signature in your browser, then return here.")
|
||||
|
||||
// Wait for authentication result with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case result := <-authServer.result:
|
||||
fmt.Println("✅ Authentication successful!")
|
||||
return convertAuthResult(result), nil
|
||||
case err := <-authServer.err:
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||
}
|
||||
}
|
||||
|
||||
// NewAuthServer creates a new authentication callback server
|
||||
func NewAuthServer() (*AuthServer, error) {
|
||||
// Listen on random available port
|
||||
listener, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create listener: %w", err)
|
||||
}
|
||||
|
||||
authServer := &AuthServer{
|
||||
listener: listener,
|
||||
result: make(chan WalletAuthResult, 1),
|
||||
err: make(chan error, 1),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/callback", authServer.handleCallback)
|
||||
mux.HandleFunc("/health", authServer.handleHealth)
|
||||
|
||||
authServer.server = &http.Server{
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in background
|
||||
go func() {
|
||||
if err := authServer.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
authServer.err <- fmt.Errorf("auth server error: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return authServer, nil
|
||||
}
|
||||
|
||||
// GetPort returns the port the server is listening on
|
||||
func (as *AuthServer) GetPort() int {
|
||||
return as.listener.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// Close shuts down the authentication server
|
||||
func (as *AuthServer) Close() error {
|
||||
as.mu.Lock()
|
||||
defer as.mu.Unlock()
|
||||
|
||||
if as.done {
|
||||
return nil
|
||||
}
|
||||
as.done = true
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return as.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// handleCallback processes the authentication callback from the gateway
|
||||
func (as *AuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
as.mu.Lock()
|
||||
if as.done {
|
||||
as.mu.Unlock()
|
||||
return
|
||||
}
|
||||
as.mu.Unlock()
|
||||
|
||||
// Parse query parameters
|
||||
query := r.URL.Query()
|
||||
|
||||
// Check for error
|
||||
if errMsg := query.Get("error"); errMsg != "" {
|
||||
as.err <- fmt.Errorf("authentication error: %s", errMsg)
|
||||
http.Error(w, "Authentication failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract authentication result
|
||||
result := WalletAuthResult{
|
||||
APIKey: query.Get("api_key"),
|
||||
RefreshToken: query.Get("refresh_token"),
|
||||
Namespace: query.Get("namespace"),
|
||||
Wallet: query.Get("wallet"),
|
||||
Plan: query.Get("plan"),
|
||||
ExpiresAt: query.Get("expires_at"),
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if result.APIKey == "" || result.Namespace == "" {
|
||||
as.err <- fmt.Errorf("incomplete authentication response: missing api_key or namespace")
|
||||
http.Error(w, "Incomplete authentication response", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Send success response to browser
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Successful</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f5f5f5; }
|
||||
.container { background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 500px; margin: 0 auto; }
|
||||
.success { color: #4CAF50; font-size: 48px; margin-bottom: 20px; }
|
||||
.details { background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; text-align: left; }
|
||||
.key { font-family: monospace; background: #e9ecef; padding: 10px; border-radius: 3px; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success">✅</div>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p>You have successfully authenticated with your wallet.</p>
|
||||
|
||||
<div class="details">
|
||||
<h3>🔑 Your Credentials:</h3>
|
||||
<p><strong>API Key:</strong></p>
|
||||
<div class="key">%s</div>
|
||||
<p><strong>Namespace:</strong> %s</p>
|
||||
<p><strong>Wallet:</strong> %s</p>
|
||||
%s
|
||||
</div>
|
||||
|
||||
<p>Your credentials have been saved securely to <code>~/.debros/credentials.json</code></p>
|
||||
<p><strong>You can now close this browser window and return to your terminal.</strong></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
result.APIKey,
|
||||
result.Namespace,
|
||||
result.Wallet,
|
||||
func() string {
|
||||
if result.Plan != "" {
|
||||
return fmt.Sprintf("<p><strong>Plan:</strong> %s</p>", result.Plan)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
)
|
||||
|
||||
// Send result to waiting goroutine
|
||||
select {
|
||||
case as.result <- result:
|
||||
// Success
|
||||
default:
|
||||
// Channel full, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// handleHealth provides a simple health check endpoint
|
||||
func (as *AuthServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"server": "debros-auth-callback",
|
||||
})
|
||||
}
|
||||
|
||||
// convertAuthResult converts WalletAuthResult to Credentials
|
||||
func convertAuthResult(result WalletAuthResult) *Credentials {
|
||||
creds := &Credentials{
|
||||
APIKey: result.APIKey,
|
||||
Namespace: result.Namespace,
|
||||
UserID: result.Wallet,
|
||||
Wallet: result.Wallet,
|
||||
IssuedAt: time.Now(),
|
||||
Plan: result.Plan,
|
||||
}
|
||||
|
||||
// Set refresh token if provided
|
||||
if result.RefreshToken != "" {
|
||||
creds.RefreshToken = result.RefreshToken
|
||||
}
|
||||
|
||||
// Parse expiration if provided
|
||||
if result.ExpiresAt != "" {
|
||||
if expTime, err := time.Parse(time.RFC3339, result.ExpiresAt); err == nil {
|
||||
creds.ExpiresAt = expTime
|
||||
}
|
||||
}
|
||||
|
||||
return creds
|
||||
}
|
||||
|
||||
// openBrowser opens the default browser to the specified URL
|
||||
func openBrowser(url string) error {
|
||||
var cmd string
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = "cmd"
|
||||
args = []string{"/c", "start"}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
default: // "linux", "freebsd", "openbsd", "netbsd"
|
||||
cmd = "xdg-open"
|
||||
}
|
||||
args = append(args, url)
|
||||
|
||||
return exec.Command(cmd, args...).Start()
|
||||
}
|
||||
|
||||
// GenerateRandomString generates a cryptographically secure random string
|
||||
func GenerateRandomString(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes)[:length], nil
|
||||
}
|
||||
|
||||
// ValidateWalletAddress validates that a wallet address is properly formatted
|
||||
func ValidateWalletAddress(address string) bool {
|
||||
// Remove 0x prefix if present
|
||||
addr := strings.TrimPrefix(strings.ToLower(address), "0x")
|
||||
|
||||
// Check length (Ethereum addresses are 40 hex characters)
|
||||
if len(addr) != 40 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if all characters are hex
|
||||
_, err := hex.DecodeString(addr)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// FormatWalletAddress formats a wallet address consistently
|
||||
func FormatWalletAddress(address string) string {
|
||||
addr := strings.TrimPrefix(strings.ToLower(address), "0x")
|
||||
return "0x" + addr
|
||||
}
|
@ -350,6 +350,11 @@ func (c *Client) getAppNamespace() string {
|
||||
|
||||
// requireAccess enforces that credentials are present and that any context-based namespace overrides match
|
||||
func (c *Client) requireAccess(ctx context.Context) error {
|
||||
// Allow internal system operations to bypass authentication
|
||||
if IsInternalContext(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := c.Config()
|
||||
if cfg == nil || (strings.TrimSpace(cfg.APIKey) == "" && strings.TrimSpace(cfg.JWT) == "") {
|
||||
return fmt.Errorf("access denied: API key or JWT required")
|
||||
|
@ -7,6 +7,14 @@ import (
|
||||
"git.debros.io/DeBros/network/pkg/storage"
|
||||
)
|
||||
|
||||
// contextKey for internal operations
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// ctxKeyInternal marks contexts for internal system operations that bypass auth
|
||||
ctxKeyInternal contextKey = "internal_operation"
|
||||
)
|
||||
|
||||
// WithNamespace applies both storage and pubsub namespace overrides to the context.
|
||||
// It is a convenience helper for client callers to ensure both subsystems receive
|
||||
// the same, consistent namespace override.
|
||||
@ -15,3 +23,19 @@ func WithNamespace(ctx context.Context, ns string) context.Context {
|
||||
ctx = pubsub.WithNamespace(ctx, ns)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// WithInternalAuth creates a context that bypasses authentication for internal system operations.
|
||||
// This should only be used by the system itself (migrations, internal tasks, etc.)
|
||||
func WithInternalAuth(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyInternal, true)
|
||||
}
|
||||
|
||||
// IsInternalContext checks if a context is marked for internal operations
|
||||
func IsInternalContext(ctx context.Context) bool {
|
||||
if v := ctx.Value(ctxKeyInternal); v != nil {
|
||||
if internal, ok := v.(bool); ok {
|
||||
return internal
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ func (d *DatabaseClientImpl) Query(ctx context.Context, sql string, args ...inte
|
||||
}
|
||||
|
||||
if err := d.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
// Determine if this is a read or write operation
|
||||
@ -265,7 +265,7 @@ func (d *DatabaseClientImpl) Transaction(ctx context.Context, queries []string)
|
||||
}
|
||||
|
||||
if err := d.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
maxRetries := 3
|
||||
@ -307,7 +307,7 @@ func (d *DatabaseClientImpl) CreateTable(ctx context.Context, schema string) err
|
||||
}
|
||||
|
||||
if err := d.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
return d.withRetry(func(conn *gorqlite.Connection) error {
|
||||
@ -322,10 +322,6 @@ func (d *DatabaseClientImpl) DropTable(ctx context.Context, tableName string) er
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return d.withRetry(func(conn *gorqlite.Connection) error {
|
||||
dropSQL := fmt.Sprintf("DROP TABLE IF EXISTS %s", tableName)
|
||||
_, err := conn.WriteOne(dropSQL)
|
||||
@ -340,7 +336,7 @@ func (d *DatabaseClientImpl) GetSchema(ctx context.Context) (*SchemaInfo, error)
|
||||
}
|
||||
|
||||
if err := d.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
// Get RQLite connection
|
||||
@ -417,7 +413,7 @@ func (s *StorageClientImpl) Get(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
if err := s.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
return s.storageClient.Get(ctx, key)
|
||||
@ -430,7 +426,7 @@ func (s *StorageClientImpl) Put(ctx context.Context, key string, value []byte) e
|
||||
}
|
||||
|
||||
if err := s.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
err := s.storageClient.Put(ctx, key, value)
|
||||
@ -448,7 +444,7 @@ func (s *StorageClientImpl) Delete(ctx context.Context, key string) error {
|
||||
}
|
||||
|
||||
if err := s.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
err := s.storageClient.Delete(ctx, key)
|
||||
@ -466,7 +462,7 @@ func (s *StorageClientImpl) List(ctx context.Context, prefix string, limit int)
|
||||
}
|
||||
|
||||
if err := s.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
return s.storageClient.List(ctx, prefix, limit)
|
||||
@ -479,7 +475,7 @@ func (s *StorageClientImpl) Exists(ctx context.Context, key string) (bool, error
|
||||
}
|
||||
|
||||
if err := s.client.requireAccess(ctx); err != nil {
|
||||
return false, err
|
||||
return false, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
return s.storageClient.Exists(ctx, key)
|
||||
@ -497,7 +493,7 @@ func (n *NetworkInfoImpl) GetPeers(ctx context.Context) ([]PeerInfo, error) {
|
||||
}
|
||||
|
||||
if err := n.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
// Get peers from LibP2P host
|
||||
@ -557,7 +553,7 @@ func (n *NetworkInfoImpl) GetStatus(ctx context.Context) (*NetworkStatus, error)
|
||||
}
|
||||
|
||||
if err := n.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
host := n.client.host
|
||||
@ -600,7 +596,7 @@ func (n *NetworkInfoImpl) ConnectToPeer(ctx context.Context, peerAddr string) er
|
||||
}
|
||||
|
||||
if err := n.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
host := n.client.host
|
||||
@ -635,7 +631,7 @@ func (n *NetworkInfoImpl) DisconnectFromPeer(ctx context.Context, peerID string)
|
||||
}
|
||||
|
||||
if err := n.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
host := n.client.host
|
||||
|
@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.debros.io/DeBros/network/pkg/pubsub"
|
||||
)
|
||||
@ -14,7 +15,7 @@ type pubSubBridge struct {
|
||||
|
||||
func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler MessageHandler) error {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
// Convert our MessageHandler to the pubsub package MessageHandler
|
||||
pubsubHandler := func(topic string, data []byte) error {
|
||||
@ -25,21 +26,21 @@ func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler Mess
|
||||
|
||||
func (p *pubSubBridge) Publish(ctx context.Context, topic string, data []byte) error {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
return p.adapter.Publish(ctx, topic, data)
|
||||
}
|
||||
|
||||
func (p *pubSubBridge) Unsubscribe(ctx context.Context, topic string) error {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
return p.adapter.Unsubscribe(ctx, topic)
|
||||
}
|
||||
|
||||
func (p *pubSubBridge) ListTopics(ctx context.Context) ([]string, error) {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
return p.adapter.ListTopics(ctx)
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -37,7 +38,6 @@ func (g *Gateway) whoamiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"not_before": claims.Nbf,
|
||||
"expires_at": claims.Exp,
|
||||
"namespace": ns,
|
||||
"require_auth": g.cfg != nil && g.cfg.RequireAuth,
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -55,7 +55,6 @@ func (g *Gateway) whoamiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"method": "api_key",
|
||||
"api_key": key,
|
||||
"namespace": ns,
|
||||
"require_auth": g.cfg != nil && g.cfg.RequireAuth,
|
||||
})
|
||||
}
|
||||
|
||||
@ -84,7 +83,9 @@ func (g *Gateway) challengeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
// Generate a URL-safe random nonce (32 bytes)
|
||||
buf := make([]byte, 32)
|
||||
@ -152,7 +153,9 @@ func (g *Gateway) verifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
@ -274,7 +277,9 @@ func (g *Gateway) issueAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
@ -297,13 +302,17 @@ func (g *Gateway) issueAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
prefix := []byte("\x19Ethereum Signed Message:\n" + strconv.Itoa(len(msg)))
|
||||
hash := ethcrypto.Keccak256(prefix, msg)
|
||||
sigHex := strings.TrimSpace(req.Signature)
|
||||
if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") { sigHex = sigHex[2:] }
|
||||
if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") {
|
||||
sigHex = sigHex[2:]
|
||||
}
|
||||
sig, err := hex.DecodeString(sigHex)
|
||||
if err != nil || len(sig) != 65 {
|
||||
writeError(w, http.StatusBadRequest, "invalid signature format")
|
||||
return
|
||||
}
|
||||
if sig[64] >= 27 { sig[64] -= 27 }
|
||||
if sig[64] >= 27 {
|
||||
sig[64] -= 27
|
||||
}
|
||||
pub, err := ethcrypto.SigToPub(hash, sig)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "signature recovery failed")
|
||||
@ -325,7 +334,12 @@ func (g *Gateway) issueAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var apiKey string
|
||||
r1, err := db.Query(ctx, "SELECT api_keys.key FROM wallet_api_keys JOIN api_keys ON wallet_api_keys.api_key_id = api_keys.id WHERE wallet_api_keys.namespace_id = ? AND LOWER(wallet_api_keys.wallet) = LOWER(?) LIMIT 1", nsID, req.Wallet)
|
||||
if err == nil && r1 != nil && r1.Count > 0 && len(r1.Rows) > 0 && len(r1.Rows[0]) > 0 {
|
||||
if s, ok := r1.Rows[0][0].(string); ok { apiKey = s } else { b, _ := json.Marshal(r1.Rows[0][0]); _ = json.Unmarshal(b, &apiKey) }
|
||||
if s, ok := r1.Rows[0][0].(string); ok {
|
||||
apiKey = s
|
||||
} else {
|
||||
b, _ := json.Marshal(r1.Rows[0][0])
|
||||
_ = json.Unmarshal(b, &apiKey)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
// Create new API key with format ak_<random>:<namespace>
|
||||
@ -354,7 +368,13 @@ func (g *Gateway) issueAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"api_key": apiKey,
|
||||
"namespace": ns,
|
||||
"plan": func() string { if strings.TrimSpace(req.Plan) == "" { return "free" } else { return req.Plan } }(),
|
||||
"plan": func() string {
|
||||
if strings.TrimSpace(req.Plan) == "" {
|
||||
return "free"
|
||||
} else {
|
||||
return req.Plan
|
||||
}
|
||||
}(),
|
||||
"wallet": strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X")),
|
||||
})
|
||||
}
|
||||
@ -386,7 +406,12 @@ func (g *Gateway) apiKeyToJWTHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var ns string
|
||||
if s, ok := res.Rows[0][0].(string); ok { ns = s } else { b, _ := json.Marshal(res.Rows[0][0]); _ = json.Unmarshal(b, &ns) }
|
||||
if s, ok := res.Rows[0][0].(string); ok {
|
||||
ns = s
|
||||
} else {
|
||||
b, _ := json.Marshal(res.Rows[0][0])
|
||||
_ = json.Unmarshal(b, &ns)
|
||||
}
|
||||
ns = strings.TrimSpace(ns)
|
||||
if ns == "" {
|
||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||
@ -437,7 +462,9 @@ func (g *Gateway) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
@ -551,7 +578,9 @@ func (g *Gateway) refreshHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
@ -595,6 +624,325 @@ func (g *Gateway) refreshHandler(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// loginPageHandler serves the wallet authentication login page
|
||||
func (g *Gateway) loginPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
callbackURL := r.URL.Query().Get("callback")
|
||||
if callbackURL == "" {
|
||||
writeError(w, http.StatusBadRequest, "callback parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Get default namespace
|
||||
ns := strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DeBros Network - Wallet Authentication</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
max-width: 500px;
|
||||
width: 100%%;
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.step {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
text-align: left;
|
||||
}
|
||||
.step-number {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 50%%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
button {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin: 10px;
|
||||
}
|
||||
button:hover {
|
||||
background: #5a67d8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
button:disabled {
|
||||
background: #cbd5e0;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
.error {
|
||||
background: #fed7d7;
|
||||
color: #e53e3e;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
display: none;
|
||||
}
|
||||
.success {
|
||||
background: #c6f6d5;
|
||||
color: #2f855a;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
display: none;
|
||||
}
|
||||
.loading {
|
||||
display: none;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@keyframes spin {
|
||||
0%% { transform: rotate(0deg); }
|
||||
100%% { transform: rotate(360deg); }
|
||||
}
|
||||
.namespace-info {
|
||||
background: #e6fffa;
|
||||
border: 1px solid #81e6d9;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.code {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
background: #f7fafc;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">🌐 DeBros Network</div>
|
||||
<p class="subtitle">Secure Wallet Authentication</p>
|
||||
|
||||
<div class="namespace-info">
|
||||
<strong>📁 Namespace:</strong> <span class="code">%s</span>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div><span class="step-number">1</span><strong>Connect Your Wallet</strong></div>
|
||||
<p>Click the button below to connect your Ethereum wallet (MetaMask, WalletConnect, etc.)</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div><span class="step-number">2</span><strong>Sign Authentication Message</strong></div>
|
||||
<p>Your wallet will prompt you to sign a message to prove your identity. This is free and secure.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div><span class="step-number">3</span><strong>Get Your API Key</strong></div>
|
||||
<p>After signing, you'll receive an API key to access the DeBros Network.</p>
|
||||
</div>
|
||||
|
||||
<div class="error" id="error"></div>
|
||||
<div class="success" id="success"></div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Processing authentication...</p>
|
||||
</div>
|
||||
|
||||
<button onclick="connectWallet()" id="connectBtn">🔗 Connect Wallet</button>
|
||||
<button onclick="window.close()" style="background: #718096;">❌ Cancel</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const callbackURL = '%s';
|
||||
const namespace = '%s';
|
||||
let walletAddress = null;
|
||||
|
||||
async function connectWallet() {
|
||||
const btn = document.getElementById('connectBtn');
|
||||
const loading = document.getElementById('loading');
|
||||
const error = document.getElementById('error');
|
||||
const success = document.getElementById('success');
|
||||
|
||||
try {
|
||||
btn.disabled = true;
|
||||
loading.style.display = 'block';
|
||||
error.style.display = 'none';
|
||||
success.style.display = 'none';
|
||||
|
||||
// Check if MetaMask is available
|
||||
if (typeof window.ethereum === 'undefined') {
|
||||
throw new Error('Please install MetaMask or another Ethereum wallet');
|
||||
}
|
||||
|
||||
// Request account access
|
||||
const accounts = await window.ethereum.request({
|
||||
method: 'eth_requestAccounts'
|
||||
});
|
||||
|
||||
if (accounts.length === 0) {
|
||||
throw new Error('No wallet accounts found');
|
||||
}
|
||||
|
||||
walletAddress = accounts[0];
|
||||
console.log('Connected to wallet:', walletAddress);
|
||||
|
||||
// Step 1: Get challenge nonce
|
||||
const challengeResponse = await fetch('/v1/auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
wallet: walletAddress,
|
||||
purpose: 'api_key_generation',
|
||||
namespace: namespace
|
||||
})
|
||||
});
|
||||
|
||||
if (!challengeResponse.ok) {
|
||||
const errorData = await challengeResponse.json();
|
||||
throw new Error(errorData.error || 'Failed to get challenge');
|
||||
}
|
||||
|
||||
const challengeData = await challengeResponse.json();
|
||||
const nonce = challengeData.nonce;
|
||||
|
||||
console.log('Received challenge nonce:', nonce);
|
||||
|
||||
// Step 2: Sign the nonce
|
||||
const signature = await window.ethereum.request({
|
||||
method: 'personal_sign',
|
||||
params: [nonce, walletAddress]
|
||||
});
|
||||
|
||||
console.log('Signature obtained:', signature);
|
||||
|
||||
// Step 3: Get API key
|
||||
const apiKeyResponse = await fetch('/v1/auth/api-key', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
wallet: walletAddress,
|
||||
nonce: nonce,
|
||||
signature: signature,
|
||||
namespace: namespace
|
||||
})
|
||||
});
|
||||
|
||||
if (!apiKeyResponse.ok) {
|
||||
const errorData = await apiKeyResponse.json();
|
||||
throw new Error(errorData.error || 'Failed to get API key');
|
||||
}
|
||||
|
||||
const apiKeyData = await apiKeyResponse.json();
|
||||
console.log('API key received:', apiKeyData);
|
||||
|
||||
loading.style.display = 'none';
|
||||
success.innerHTML = '✅ Authentication successful! Redirecting...';
|
||||
success.style.display = 'block';
|
||||
|
||||
// Redirect to callback URL with credentials
|
||||
const params = new URLSearchParams({
|
||||
api_key: apiKeyData.api_key,
|
||||
namespace: apiKeyData.namespace,
|
||||
wallet: apiKeyData.wallet,
|
||||
plan: apiKeyData.plan || 'free'
|
||||
});
|
||||
|
||||
const redirectURL = callbackURL + '?' + params.toString();
|
||||
console.log('Redirecting to:', redirectURL);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectURL;
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Authentication error:', err);
|
||||
loading.style.display = 'none';
|
||||
error.innerHTML = '❌ ' + err.message;
|
||||
error.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect if wallet is already connected
|
||||
window.addEventListener('load', async () => {
|
||||
if (typeof window.ethereum !== 'undefined') {
|
||||
try {
|
||||
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
|
||||
if (accounts.length > 0) {
|
||||
const btn = document.getElementById('connectBtn');
|
||||
btn.innerHTML = '🔗 Continue with ' + accounts[0].slice(0, 6) + '...' + accounts[0].slice(-4);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Could not get accounts:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`, ns, callbackURL, ns)
|
||||
|
||||
fmt.Fprint(w, html)
|
||||
}
|
||||
|
||||
// logoutHandler revokes refresh tokens. If a refresh_token is provided, it will
|
||||
// be revoked. If all=true is provided (and the request is authenticated via JWT),
|
||||
// all tokens for the JWT subject within the namespace are revoked.
|
||||
@ -619,7 +967,9 @@ func (g *Gateway) logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
|
@ -18,7 +18,6 @@ type Config struct {
|
||||
ListenAddr string
|
||||
ClientNamespace string
|
||||
BootstrapPeers []string
|
||||
RequireAuth bool
|
||||
}
|
||||
|
||||
type Gateway struct {
|
||||
@ -80,9 +79,10 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
||||
// Non-blocking DB migrations: probe RQLite; if reachable, apply migrations asynchronously
|
||||
go func() {
|
||||
if gw.probeRQLiteReachable(3 * time.Second) {
|
||||
if err := gw.applyMigrations(context.Background()); err != nil {
|
||||
internalCtx := gw.withInternalAuth(context.Background())
|
||||
if err := gw.applyMigrations(internalCtx); err != nil {
|
||||
if err == errNoMigrationsFound {
|
||||
if err2 := gw.applyAutoMigrations(context.Background()); err2 != nil {
|
||||
if err2 := gw.applyAutoMigrations(internalCtx); err2 != nil {
|
||||
logger.ComponentWarn(logging.ComponentDatabase, "auto migrations failed", zap.Error(err2))
|
||||
} else {
|
||||
logger.ComponentInfo(logging.ComponentDatabase, "auto migrations applied")
|
||||
@ -102,6 +102,11 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
||||
return gw, nil
|
||||
}
|
||||
|
||||
// withInternalAuth creates a context for internal gateway operations that bypass authentication
|
||||
func (g *Gateway) withInternalAuth(ctx context.Context) context.Context {
|
||||
return client.WithInternalAuth(ctx)
|
||||
}
|
||||
|
||||
// probeRQLiteReachable performs a quick GET /status against candidate endpoints with a short timeout.
|
||||
func (g *Gateway) probeRQLiteReachable(timeout time.Duration) bool {
|
||||
endpoints := client.DefaultDatabaseEndpoints()
|
||||
|
@ -56,12 +56,6 @@ func (g *Gateway) loggingMiddleware(next http.Handler) http.Handler {
|
||||
// - X-API-Key: <API key>
|
||||
func (g *Gateway) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// If auth not required, pass through.
|
||||
if g.cfg == nil || !g.cfg.RequireAuth {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Allow preflight without auth
|
||||
if r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
@ -162,7 +156,7 @@ func extractAPIKey(r *http.Request) string {
|
||||
// isPublicPath returns true for routes that should be accessible without API key auth
|
||||
func isPublicPath(p string) bool {
|
||||
switch p {
|
||||
case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key":
|
||||
case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/login", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@ -173,8 +167,8 @@ func isPublicPath(p string) bool {
|
||||
// for certain protected paths (e.g., apps CRUD and storage APIs).
|
||||
func (g *Gateway) authorizationMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip if auth not required or for public/OPTIONS paths
|
||||
if g.cfg == nil || !g.cfg.RequireAuth || r.Method == http.MethodOptions || isPublicPath(r.URL.Path) {
|
||||
// Skip for public/OPTIONS paths only
|
||||
if r.Method == http.MethodOptions || isPublicPath(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.debros.io/DeBros/network/pkg/client"
|
||||
"git.debros.io/DeBros/network/pkg/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@ -21,6 +22,9 @@ func (g *Gateway) applyAutoMigrations(ctx context.Context) error {
|
||||
}
|
||||
db := g.client.Database()
|
||||
|
||||
// Use internal context to bypass authentication for system migrations
|
||||
internalCtx := client.WithInternalAuth(ctx)
|
||||
|
||||
stmts := []string{
|
||||
// namespaces
|
||||
"CREATE TABLE IF NOT EXISTS namespaces (\n\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n\t name TEXT NOT NULL UNIQUE,\n\t created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",
|
||||
@ -35,8 +39,8 @@ func (g *Gateway) applyAutoMigrations(ctx context.Context) error {
|
||||
"INSERT OR IGNORE INTO namespaces(name) VALUES ('default')",
|
||||
}
|
||||
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Query(ctx, s); err != nil {
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Query(internalCtx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -49,8 +53,11 @@ func (g *Gateway) applyMigrations(ctx context.Context) error {
|
||||
}
|
||||
db := g.client.Database()
|
||||
|
||||
// Use internal context to bypass authentication for system migrations
|
||||
internalCtx := client.WithInternalAuth(ctx)
|
||||
|
||||
// Ensure schema_migrations exists first
|
||||
if _, err := db.Query(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (\n\tversion INTEGER PRIMARY KEY,\n\tapplied_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))\n)"); err != nil {
|
||||
if _, err := db.Query(internalCtx, "CREATE TABLE IF NOT EXISTS schema_migrations (\n\tversion INTEGER PRIMARY KEY,\n\tapplied_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))\n)"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -64,12 +71,19 @@ func (g *Gateway) applyMigrations(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type mig struct{ ver int; path string }
|
||||
type mig struct {
|
||||
ver int
|
||||
path string
|
||||
}
|
||||
migrations := make([]mig, 0)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() { continue }
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".sql") { continue }
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
|
||||
continue
|
||||
}
|
||||
if ver, ok := parseMigrationVersion(name); ok {
|
||||
migrations = append(migrations, mig{ver: ver, path: filepath.Join(migDir, name)})
|
||||
}
|
||||
@ -82,28 +96,36 @@ func (g *Gateway) applyMigrations(ctx context.Context) error {
|
||||
// Helper to check if version applied
|
||||
isApplied := func(ctx context.Context, v int) (bool, error) {
|
||||
res, err := db.Query(ctx, "SELECT 1 FROM schema_migrations WHERE version = ? LIMIT 1", v)
|
||||
if err != nil { return false, err }
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res != nil && res.Count > 0, nil
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
applied, err := isApplied(ctx, m.ver)
|
||||
if err != nil { return err }
|
||||
applied, err := isApplied(internalCtx, m.ver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
// Read and split SQL file into statements
|
||||
content, err := os.ReadFile(m.path)
|
||||
if err != nil { return err }
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmts := splitSQLStatements(string(content))
|
||||
for _, s := range stmts {
|
||||
if s == "" { continue }
|
||||
if _, err := db.Query(ctx, s); err != nil {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Query(internalCtx, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Mark as applied
|
||||
if _, err := db.Query(ctx, "INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)", m.ver); err != nil {
|
||||
if _, err := db.Query(internalCtx, "INSERT INTO schema_migrations (version) VALUES (?)", m.ver); err != nil {
|
||||
return err
|
||||
}
|
||||
g.logger.ComponentInfo(logging.ComponentDatabase, "applied migration", zap.Int("version", m.ver), zap.String("file", m.path))
|
||||
@ -116,9 +138,13 @@ func parseMigrationVersion(name string) (int, bool) {
|
||||
for i < len(name) && name[i] >= '0' && name[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 { return 0, false }
|
||||
if i == 0 {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.Atoi(name[:i])
|
||||
if err != nil { return 0, false }
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
@ -127,8 +153,16 @@ func splitSQLStatements(sqlText string) []string {
|
||||
cleaned := make([]string, 0, len(lines))
|
||||
for _, ln := range lines {
|
||||
s := strings.TrimSpace(ln)
|
||||
if s == "" { continue }
|
||||
if strings.HasPrefix(s, "--") { continue }
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
// Handle inline comments by removing everything after --
|
||||
if commentIdx := strings.Index(s, "--"); commentIdx >= 0 {
|
||||
s = strings.TrimSpace(s[:commentIdx])
|
||||
if s == "" {
|
||||
continue // line was only a comment
|
||||
}
|
||||
}
|
||||
upper := strings.ToUpper(s)
|
||||
if upper == "BEGIN;" || upper == "COMMIT;" || upper == "BEGIN" || upper == "COMMIT" {
|
||||
continue
|
||||
@ -145,8 +179,10 @@ func splitSQLStatements(sqlText string) []string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
sp := strings.TrimSpace(p)
|
||||
if sp == "" { continue }
|
||||
out = append(out, sp)
|
||||
if sp == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, sp+";")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ func (g *Gateway) Routes() http.Handler {
|
||||
// auth endpoints
|
||||
mux.HandleFunc("/v1/auth/jwks", g.jwksHandler)
|
||||
mux.HandleFunc("/.well-known/jwks.json", g.jwksHandler)
|
||||
mux.HandleFunc("/v1/auth/login", g.loginPageHandler)
|
||||
mux.HandleFunc("/v1/auth/challenge", g.challengeHandler)
|
||||
mux.HandleFunc("/v1/auth/verify", g.verifyHandler)
|
||||
// New: issue JWT from API key; new: create or return API key for a wallet after verification
|
||||
|
Loading…
x
Reference in New Issue
Block a user