diff --git a/nginx/nginx.conf b/nginx/nginx.conf index e242d9e9bb..ab080179f7 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -269,4 +269,44 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } } + + # HTTP server for Accounts Console (Let's Encrypt + redirect) + server { + listen 80; + listen [::]:80; + server_name accounts.prod.eagle0.net accounts.eagle0.net; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } + } + + # HTTPS server for Accounts Console (user self-service portal) + server { + listen 443 ssl; + listen [::]:443 ssl; + server_name accounts.prod.eagle0.net accounts.eagle0.net; + + ssl_certificate /etc/letsencrypt/live/accounts.eagle0.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/accounts.eagle0.net/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + location / { + proxy_pass http://admin:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } } diff --git a/src/main/go/net/eagle0/admin_server/admin_server.go b/src/main/go/net/eagle0/admin_server/admin_server.go index 9f35f8b853..ac9316befd 100644 --- a/src/main/go/net/eagle0/admin_server/admin_server.go +++ b/src/main/go/net/eagle0/admin_server/admin_server.go @@ -78,6 +78,8 @@ var ( invitationsRowsTemplate *template.Template accountsTemplate *template.Template loginTemplate *template.Template + myAccountTemplate *template.Template + goodbyeTemplate *template.Template ) func main() { @@ -150,6 +152,16 @@ func main() { log.Fatalf("Failed to parse login template: %v", err) } + myAccountTemplate, err = template.ParseFS(templatesFS, "templates/my_account_layout.html", "templates/my_account.html") + if err != nil { + log.Fatalf("Failed to parse my account template: %v", err) + } + + goodbyeTemplate, err = template.ParseFS(templatesFS, "templates/goodbye.html") + if err != nil { + log.Fatalf("Failed to parse goodbye template: %v", err) + } + // Log configuration at startup log.Printf("Configuration: eagle-addr=%s, auth-addr=%s, auth-tls=%v", *eagleAddr, *authAddr, *authTLS) @@ -195,6 +207,11 @@ func main() { // Set up HTTP routes // All admin routes require authentication except login/logout/health + // accounts.eagle0.net routes for user self-service + http.HandleFunc("/my-account", requireUser(handleMyAccountPage)) + http.HandleFunc("/my-account/delete", requireUser(handleMyAccountDelete)) + http.HandleFunc("/goodbye", handleGoodbyePage) + // Admin and accounts portal share common routes (differentiated by host) http.HandleFunc("/", handleIndex) http.HandleFunc("/games", requireAuth(handleGamesPage)) http.HandleFunc("/games/upload", requireAuth(handleGameUpload)) // Must be before /games/ to match first @@ -231,11 +248,27 @@ func main() { } } +// isAccountsHost returns true if the request is for accounts.eagle0.net +func isAccountsHost(r *http.Request) bool { + return strings.HasPrefix(r.Host, "accounts.") +} + +// isAdminHost returns true if the request is for admin.eagle0.net or localhost +func isAdminHost(r *http.Request) bool { + return strings.HasPrefix(r.Host, "admin.") || strings.HasPrefix(r.Host, "localhost") +} + func handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } + + // Route based on host + if isAccountsHost(r) { + http.Redirect(w, r, "/my-account", http.StatusFound) + return + } http.Redirect(w, r, "/games", http.StatusFound) } @@ -2360,9 +2393,10 @@ const ( // LoginPageData is the data for the login page type LoginPageData struct { - Title string - Error string - BuildInfo BuildInfo + Title string + Error string + BuildInfo BuildInfo + IsAccountsHost bool // true if on accounts.eagle0.net } // requireAuth wraps a handler to require admin authentication @@ -2398,20 +2432,74 @@ func requireAuth(next http.HandlerFunc) http.HandlerFunc { } } -func handleLoginPage(w http.ResponseWriter, r *http.Request) { - // If already logged in with valid token, redirect to users - token := getAdminToken(r) - if token != "" { - ctx, cancel := createAdminContext(r) - defer cancel() - if _, err := adminClient.ListUsers(ctx, &adminpb.ListUsersRequest{Limit: 1}); err == nil { - redirect := r.URL.Query().Get("redirect") - if redirect == "" { - redirect = "/users" - } - http.Redirect(w, r, redirect, http.StatusFound) +// requireUser wraps a handler to require user authentication (not necessarily admin) +// Used for accounts.eagle0.net self-service portal +func requireUser(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := getAdminToken(r) + if token == "" { + http.Redirect(w, r, "/login?redirect="+r.URL.Path, http.StatusFound) return } + + // Validate token by calling GetCurrentUser (any authenticated user can call this) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + md := metadata.Pairs("authorization", "Bearer "+token) + ctx = metadata.NewOutgoingContext(ctx, md) + + _, err := authClient.GetCurrentUser(ctx, &authpb.GetCurrentUserRequest{}) + if err != nil { + log.Printf("User auth check failed: %v", err) + // Clear invalid token + http.SetCookie(w, &http.Cookie{ + Name: adminTokenCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, "/login?error=session_expired&redirect="+r.URL.Path, http.StatusFound) + return + } + + next(w, r) + } +} + +func handleLoginPage(w http.ResponseWriter, r *http.Request) { + isAccounts := isAccountsHost(r) + token := getAdminToken(r) + + // If already logged in with valid token, redirect appropriately + if token != "" { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + md := metadata.Pairs("authorization", "Bearer "+token) + ctx = metadata.NewOutgoingContext(ctx, md) + + if isAccounts { + // For accounts portal, just check if user is authenticated (not admin required) + if _, err := authClient.GetCurrentUser(ctx, &authpb.GetCurrentUserRequest{}); err == nil { + redirect := r.URL.Query().Get("redirect") + if redirect == "" { + redirect = "/my-account" + } + http.Redirect(w, r, redirect, http.StatusFound) + return + } + } else { + // For admin portal, require admin privileges + if _, err := adminClient.ListUsers(ctx, &adminpb.ListUsersRequest{Limit: 1}); err == nil { + redirect := r.URL.Query().Get("redirect") + if redirect == "" { + redirect = "/games" + } + http.Redirect(w, r, redirect, http.StatusFound) + return + } + } } errorMsg := "" @@ -2429,9 +2517,10 @@ func handleLoginPage(w http.ResponseWriter, r *http.Request) { } data := LoginPageData{ - Title: "Login", - Error: errorMsg, - BuildInfo: getBuildInfo(), + Title: "Login", + Error: errorMsg, + BuildInfo: getBuildInfo(), + IsAccountsHost: isAccounts, } w.Header().Set("Content-Type", "text/html") @@ -2499,6 +2588,8 @@ func handleLoginStart(w http.ResponseWriter, r *http.Request) { } func handleLoginComplete(w http.ResponseWriter, r *http.Request) { + isAccounts := isAccountsHost(r) + // Get state from cookie stateCookie, err := r.Cookie(oauthStateCookie) if err != nil || stateCookie.Value == "" { @@ -2519,24 +2610,33 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) { return } + // Determine redirect target based on host + successRedirect := "/games" + if isAccounts { + successRedirect = "/my-account" + } + switch resp.Status { case authpb.OAuthStatus_OAUTH_STATUS_SUCCESS: - // Check if user is admin by trying to call admin service md := metadata.Pairs("authorization", "Bearer "+resp.AccessToken) adminCtx := metadata.NewOutgoingContext(ctx, md) - _, err := adminClient.ListUsers(adminCtx, &adminpb.ListUsersRequest{Limit: 1}) - if err != nil { - log.Printf("Admin check failed (auth server: %s, TLS: %v): %v", *authAddr, *authTLS, err) - // Clear cookies - http.SetCookie(w, &http.Cookie{ - Name: oauthStateCookie, - Value: "", - Path: "/", - MaxAge: -1, - }) - http.Redirect(w, r, "/login?error=not_admin", http.StatusFound) - return + // For admin portal, require admin privileges + // For accounts portal, any authenticated user is allowed + if !isAccounts { + _, err := adminClient.ListUsers(adminCtx, &adminpb.ListUsersRequest{Limit: 1}) + if err != nil { + log.Printf("Admin check failed (auth server: %s, TLS: %v): %v", *authAddr, *authTLS, err) + // Clear cookies + http.SetCookie(w, &http.Cookie{ + Name: oauthStateCookie, + Value: "", + Path: "/", + MaxAge: -1, + }) + http.Redirect(w, r, "/login?error=not_admin", http.StatusFound) + return + } } // Set admin token cookie @@ -2557,8 +2657,7 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) { MaxAge: -1, }) - // Redirect to games (the main admin page) - http.Redirect(w, r, "/games", http.StatusFound) + http.Redirect(w, r, successRedirect, http.StatusFound) case authpb.OAuthStatus_OAUTH_STATUS_PENDING: // OAuth not complete yet - show polling page (shouldn't happen with redirect flow) @@ -2566,7 +2665,7 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `
-