diff --git a/app/main.go b/app/main.go index ee6caca..50651d8 100644 --- a/app/main.go +++ b/app/main.go @@ -45,6 +45,8 @@ func Run( publishers: publish.New(NewPublisher(ctx)), Admins: adminKeys, } + // Initialize the user interface + l.UserInterface() addr := fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port) log.I.F("starting listener on http://%s", addr) go func() { diff --git a/app/server.go b/app/server.go index faec112..e71f789 100644 --- a/app/server.go +++ b/app/server.go @@ -2,13 +2,19 @@ package app import ( "context" + "encoding/json" + "io" "net/http" "strconv" "strings" + "sync" "lol.mleku.dev/chk" "next.orly.dev/app/config" "next.orly.dev/pkg/database" + "next.orly.dev/pkg/encoders/event" + "next.orly.dev/pkg/encoders/hex" + "next.orly.dev/pkg/protocol/auth" "next.orly.dev/pkg/protocol/publish" ) @@ -20,6 +26,10 @@ type Server struct { publishers *publish.S Admins [][]byte *database.D + + // Challenge storage for HTTP UI authentication + challengeMutex sync.RWMutex + challenges map[string][]byte } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -70,3 +80,288 @@ func (s *Server) ServiceURL(req *http.Request) (st string) { } return proto + "://" + host } + +// UserInterface sets up a basic Nostr NDK interface that allows users to log into the relay user interface +func (s *Server) UserInterface() { + if s.mux == nil { + s.mux = http.NewServeMux() + } + + // Initialize challenge storage if not already done + if s.challenges == nil { + s.challengeMutex.Lock() + s.challenges = make(map[string][]byte) + s.challengeMutex.Unlock() + } + + // Serve the main login interface + s.mux.HandleFunc("/", s.handleLoginInterface) + + // API endpoints for authentication + s.mux.HandleFunc("/api/auth/challenge", s.handleAuthChallenge) + s.mux.HandleFunc("/api/auth/login", s.handleAuthLogin) + s.mux.HandleFunc("/api/auth/status", s.handleAuthStatus) +} + +// handleLoginInterface serves the main user interface for login +func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + html := ` + + + Nostr Relay Login + + + + + +
+

Nostr Relay Authentication

+

Connect to this Nostr relay using your private key or browser extension.

+ +
+ Ready to authenticate +
+ +
+ +
+ +
+ + + +
+ +
+ +
+
+ + + +` + + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(html)) +} + +// handleAuthChallenge generates and returns an authentication challenge +func (s *Server) handleAuthChallenge(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Generate a proper challenge using the auth package + challenge := auth.GenerateChallenge() + challengeHex := hex.Enc(challenge) + + // Store the challenge using the hex value as the key for easy lookup + s.challengeMutex.Lock() + s.challenges[challengeHex] = challenge + s.challengeMutex.Unlock() + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"challenge": "` + challengeHex + `"}`)) +} + +// handleAuthLogin processes authentication requests +func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + // Read the request body + body, err := io.ReadAll(r.Body) + if chk.E(err) { + w.Write([]byte(`{"success": false, "error": "Failed to read request body"}`)) + return + } + + // Parse the signed event + var evt event.E + if err = json.Unmarshal(body, &evt); chk.E(err) { + w.Write([]byte(`{"success": false, "error": "Invalid event format"}`)) + return + } + + // Extract the challenge from the event to look up the stored challenge + challengeTag := evt.Tags.GetFirst([]byte("challenge")) + if challengeTag == nil { + w.Write([]byte(`{"success": false, "error": "Challenge tag missing from event"}`)) + return + } + + challengeHex := string(challengeTag.Value()) + + // Retrieve the stored challenge + s.challengeMutex.RLock() + _, exists := s.challenges[challengeHex] + s.challengeMutex.RUnlock() + + if !exists { + w.Write([]byte(`{"success": false, "error": "Invalid or expired challenge"}`)) + return + } + + // Clean up the used challenge + s.challengeMutex.Lock() + delete(s.challenges, challengeHex) + s.challengeMutex.Unlock() + + relayURL := s.ServiceURL(r) + + // Validate the authentication event with the correct challenge + // The challenge in the event tag is hex-encoded, so we need to pass the hex string as bytes + ok, err := auth.Validate(&evt, []byte(challengeHex), relayURL) + if chk.E(err) || !ok { + errorMsg := "Authentication validation failed" + if err != nil { + errorMsg = err.Error() + } + w.Write([]byte(`{"success": false, "error": "` + errorMsg + `"}`)) + return + } + + // Authentication successful + w.Write([]byte(`{"success": true, "pubkey": "` + hex.Enc(evt.Pubkey) + `", "message": "Authentication successful"}`)) +} + +// handleAuthStatus returns the current authentication status +func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"authenticated": false}`)) +}