From ecf7f78a066dcecaa743ae05a69739af32f7ad66 Mon Sep 17 00:00:00 2001 From: Dan Crosby Date: Sat, 31 Jan 2026 14:46:42 -0800 Subject: [PATCH] Add accounts.eagle0.net user self-service portal (#5760) Implements a user-facing account management portal at accounts.eagle0.net that allows non-admin users to view their account information and delete their account. The same admin server backend handles both admin.eagle0.net (admin-only) and accounts.eagle0.net (user self-service), distinguished by Host header. Changes: - nginx: Add server blocks for accounts.eagle0.net - admin_server: Add host detection (isAccountsHost/isAdminHost) - admin_server: Add requireUser auth wrapper (auth without admin check) - admin_server: Add /my-account, /my-account/delete, /goodbye routes - admin_server: Modify login flow to not require admin for accounts portal - templates: Add my_account_layout.html, my_account.html, goodbye.html - templates: Update login.html to show different text per portal Post-deploy manual steps required: 1. DNS: Add A record for accounts.eagle0.net -> droplet IP 2. SSL: Run certbot on droplet for accounts.eagle0.net certificate Co-authored-by: Claude Opus 4.5 --- nginx/nginx.conf | 40 ++ .../net/eagle0/admin_server/admin_server.go | 391 +++++++++++++++--- .../admin_server/templates/goodbye.html | 79 ++++ .../eagle0/admin_server/templates/login.html | 9 + .../admin_server/templates/my_account.html | 297 +++++++++++++ .../templates/my_account_layout.html | 23 ++ 6 files changed, 786 insertions(+), 53 deletions(-) create mode 100644 src/main/go/net/eagle0/admin_server/templates/goodbye.html create mode 100644 src/main/go/net/eagle0/admin_server/templates/my_account.html create mode 100644 src/main/go/net/eagle0/admin_server/templates/my_account_layout.html 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, ` - Completing Login - Eagle Admin + Completing Login - Eagle0 @@ -2581,12 +2680,13 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) { -`) +`, successRedirect) case authpb.OAuthStatus_OAUTH_STATUS_FAILED: http.Redirect(w, r, "/login?error="+resp.ErrorMessage, http.StatusFound) @@ -2617,6 +2717,7 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) { } func handleLoginCheck(w http.ResponseWriter, r *http.Request) { + isAccounts := isAccountsHost(r) w.Header().Set("Content-Type", "application/json") // Get state from cookie @@ -2644,6 +2745,12 @@ func handleLoginCheck(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_PENDING: json.NewEncoder(w).Encode(map[string]interface{}{ @@ -2651,25 +2758,28 @@ func handleLoginCheck(w http.ResponseWriter, r *http.Request) { }) 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("User is not admin: %v", err) - // Clear oauth state cookie - http.SetCookie(w, &http.Cookie{ - Name: oauthStateCookie, - Value: "", - Path: "/", - MaxAge: -1, - }) - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": false, - "error": "not_admin", - }) - 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("User is not admin: %v", err) + // Clear oauth state cookie + http.SetCookie(w, &http.Cookie{ + Name: oauthStateCookie, + Value: "", + Path: "/", + MaxAge: -1, + }) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": "not_admin", + }) + return + } } // Set admin token cookie @@ -2692,7 +2802,7 @@ func handleLoginCheck(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, - "redirect": "/games", + "redirect": successRedirect, }) case authpb.OAuthStatus_OAUTH_STATUS_FAILED: @@ -2726,5 +2836,180 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { SameSite: http.SameSiteLaxMode, }) + // Redirect based on host + if isAccountsHost(r) { + http.Redirect(w, r, "/goodbye", http.StatusFound) + return + } http.Redirect(w, r, "/login", http.StatusFound) } + +// ============================================================================= +// Account Self-Service Handlers (for accounts.eagle0.net) +// ============================================================================= + +// MyAccountPageData is the data for the user's account page +type MyAccountPageData struct { + Title string + UserID string + DisplayName string + AvatarURL string + CreatedAt string + LastLoginAt string + OAuthIdentities []OAuthIdentityInfo + BuildInfo BuildInfo +} + +// handleMyAccountPage shows the current user's account information +func handleMyAccountPage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + token := getAdminToken(r) + if token == "" { + http.Redirect(w, r, "/login?redirect=/my-account", http.StatusFound) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + md := metadata.Pairs("authorization", "Bearer "+token) + ctx = metadata.NewOutgoingContext(ctx, md) + + // Get current user info via auth service + userResp, err := authClient.GetCurrentUser(ctx, &authpb.GetCurrentUserRequest{}) + if err != nil { + log.Printf("Failed to get current user: %v", err) + http.Redirect(w, r, "/login?error=session_expired&redirect=/my-account", http.StatusFound) + return + } + + user := userResp.User + + // Try to get more detailed info via admin service (if user is admin, this will work) + // For non-admin users, we use the basic info from GetCurrentUser + var identities []OAuthIdentityInfo + createdAt := "" + lastLoginAt := "" + + // Try admin API for detailed info (only admins can call this successfully for themselves) + adminResp, err := adminClient.GetUser(ctx, &adminpb.GetUserRequest{UserId: user.UserId}) + if err == nil && adminResp.User != nil { + for _, id := range adminResp.User.OauthIdentities { + identities = append(identities, OAuthIdentityInfo{ + Provider: id.Provider, + ProviderUserID: id.ProviderUserId, + Email: id.Email, + }) + } + if adminResp.User.CreatedAt != nil { + createdAt = adminResp.User.CreatedAt.AsTime().Format("2006-01-02 15:04") + } + if adminResp.User.LastLoginAt != nil { + lastLoginAt = adminResp.User.LastLoginAt.AsTime().Format("2006-01-02 15:04") + } + } + + data := MyAccountPageData{ + Title: "My Account", + UserID: user.UserId, + DisplayName: user.DisplayName, + AvatarURL: user.AvatarUrl, + CreatedAt: createdAt, + LastLoginAt: lastLoginAt, + OAuthIdentities: identities, + BuildInfo: getBuildInfo(), + } + + w.Header().Set("Content-Type", "text/html") + if err := myAccountTemplate.ExecuteTemplate(w, "my_account_layout.html", data); err != nil { + log.Printf("Template error: %v", err) + http.Error(w, "Template error", http.StatusInternalServerError) + } +} + +// handleMyAccountDelete handles the user deleting their own account +func handleMyAccountDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + token := getAdminToken(r) + if token == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + md := metadata.Pairs("authorization", "Bearer "+token) + ctx = metadata.NewOutgoingContext(ctx, md) + + // Get current user ID + userResp, err := authClient.GetCurrentUser(ctx, &authpb.GetCurrentUserRequest{}) + if err != nil { + log.Printf("Failed to get current user: %v", err) + http.Error(w, "Failed to get user info", http.StatusInternalServerError) + return + } + + userID := userResp.User.UserId + + // Delete the user's own account via admin service + // Note: The admin service should allow users to delete their own account + deleteResp, err := adminClient.DeleteUser(ctx, &adminpb.DeleteUserRequest{ + UserId: userID, + }) + if err != nil { + log.Printf("Failed to delete user %s: %v", userID, err) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `
Failed to delete account: %v
`, err) + return + } + + if !deleteResp.Success { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `
%s
`, deleteResp.ErrorMessage) + return + } + + log.Printf("User %s deleted their own account", userID) + + // Clear auth cookie + http.SetCookie(w, &http.Cookie{ + Name: adminTokenCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + // Redirect to goodbye page + w.Header().Set("HX-Redirect", "/goodbye") + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `
Account deleted. Redirecting...
`) +} + +// GoodbyePageData is the data for the goodbye page after account deletion +type GoodbyePageData struct { + Title string +} + +// handleGoodbyePage shows the goodbye page after account deletion +func handleGoodbyePage(w http.ResponseWriter, r *http.Request) { + data := GoodbyePageData{ + Title: "Goodbye", + } + + w.Header().Set("Content-Type", "text/html") + if err := goodbyeTemplate.Execute(w, data); err != nil { + log.Printf("Template error: %v", err) + http.Error(w, "Template error", http.StatusInternalServerError) + } +} diff --git a/src/main/go/net/eagle0/admin_server/templates/goodbye.html b/src/main/go/net/eagle0/admin_server/templates/goodbye.html new file mode 100644 index 0000000000..ee1a336a1f --- /dev/null +++ b/src/main/go/net/eagle0/admin_server/templates/goodbye.html @@ -0,0 +1,79 @@ + + + + + + {{.Title}} - Eagle0 + + + + +
+
+
+
+

Goodbye

+
+ +
+

Your account has been successfully deleted.

+

All your data has been removed from our systems. We're sorry to see you go!

+
+ +
+

If you ever want to play Eagle0 again, you can create a new account at any time.

+
+
+
+
+ + + + diff --git a/src/main/go/net/eagle0/admin_server/templates/login.html b/src/main/go/net/eagle0/admin_server/templates/login.html index 6d5ed2afba..ef3586984f 100644 --- a/src/main/go/net/eagle0/admin_server/templates/login.html +++ b/src/main/go/net/eagle0/admin_server/templates/login.html @@ -2,8 +2,13 @@
+ {{if .IsAccountsHost}} +

After signing in with your OAuth provider, you'll be redirected back to manage your account.

+ {{else}}

After signing in with your OAuth provider, return here and go to /login/complete to finish the login process.

+ {{end}}
diff --git a/src/main/go/net/eagle0/admin_server/templates/my_account.html b/src/main/go/net/eagle0/admin_server/templates/my_account.html new file mode 100644 index 0000000000..394f71d8d9 --- /dev/null +++ b/src/main/go/net/eagle0/admin_server/templates/my_account.html @@ -0,0 +1,297 @@ +{{define "content"}} + + + + +{{if .OAuthIdentities}} + +{{end}} + + + + +
+
+

Delete Account?

+
+

Are you sure you want to permanently delete your account? This will:

+
    +
  • Remove all your account data
  • +
  • Unlink all OAuth providers
  • +
  • This action cannot be undone
  • +
+
+
+ + +
+
+
+ + + + +{{end}} diff --git a/src/main/go/net/eagle0/admin_server/templates/my_account_layout.html b/src/main/go/net/eagle0/admin_server/templates/my_account_layout.html new file mode 100644 index 0000000000..2c2a93b290 --- /dev/null +++ b/src/main/go/net/eagle0/admin_server/templates/my_account_layout.html @@ -0,0 +1,23 @@ + + + + + + {{.Title}} - Eagle0 + + + + + + +
+ {{template "content" .}} +
+ +