Files
at-container-registry/pkg/hold/admin/handlers_auth.go
T

158 lines
5.0 KiB
Go

package admin
import (
"log/slog"
"net/http"
"strings"
"atcr.io/pkg/atproto"
)
// loginErrorMessages maps slug-based error codes (emitted as query params by the
// auth handlers) to user-friendly sentences. Any slug not in this map falls back
// to a generic message so internal details are never exposed in the browser.
var loginErrorMessages = map[string]string{
"handle_required": "Please enter a handle or DID to sign in.",
"handle_invalid": "That handle or DID could not be found. Check the spelling and try again.",
"oauth_failed": "Couldn't start the sign-in flow. Please try again.",
"oauth_callback_failed": "Sign-in was not completed. Please try again.",
"ownership_check_failed": "Couldn't verify hold ownership. Please try again.",
"access_denied": "Only the hold owner can access the admin panel.",
}
// loginErrorMessage translates a slug into a human-readable login error.
// It is registered as the "loginError" template function.
func loginErrorMessage(slug string) string {
if msg, ok := loginErrorMessages[slug]; ok {
return msg
}
return "Couldn't sign you in. Please try again."
}
// handleLogin renders the login page
func (ui *AdminUI) handleLogin(w http.ResponseWriter, r *http.Request) {
// If already logged in, redirect to dashboard
if token, ok := getSessionCookie(r); ok {
if session := ui.getSession(token); session != nil {
// Verify still owner
if _, captain, err := ui.pds.GetCaptainRecord(r.Context()); err == nil && session.DID == captain.Owner {
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
}
}
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "Login",
ActivePage: "login",
HoldDID: ui.pds.DID(),
},
Error: r.URL.Query().Get("error"),
}
ui.renderTemplate(w, "pages/login.html", data)
}
// handleAuthorize starts the OAuth flow.
// Accepts both GET (direct URL) and POST (login form submission).
func (ui *AdminUI) handleAuthorize(w http.ResponseWriter, r *http.Request) {
handle := strings.TrimSpace(r.FormValue("handle"))
if handle == "" {
http.Redirect(w, r, "/admin/auth/login?error=handle_required", http.StatusFound)
return
}
// Normalize handle
handle = strings.TrimPrefix(handle, "@")
// Resolve handle to DID
did, _, _, err := atproto.ResolveIdentity(r.Context(), handle)
if err != nil {
slog.Warn("Failed to resolve handle for admin login", "handle", handle, "error", err)
http.Redirect(w, r, "/admin/auth/login?error=handle_invalid", http.StatusFound)
return
}
slog.Info("Starting admin OAuth flow", "handle", handle, "did", did)
// Start OAuth flow
authURL, err := ui.clientApp.StartAuthFlow(r.Context(), did)
if err != nil {
slog.Error("Failed to start OAuth flow", "error", err)
http.Redirect(w, r, "/admin/auth/login?error=oauth_failed", http.StatusFound)
return
}
http.Redirect(w, r, authURL, http.StatusFound)
}
// handleCallback processes the OAuth callback
func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Process OAuth callback
sessionData, err := ui.clientApp.ProcessCallback(ctx, r.URL.Query())
if err != nil {
slog.Error("OAuth callback failed", "error", err)
http.Redirect(w, r, "/admin/auth/login?error=oauth_callback_failed", http.StatusFound)
return
}
did := sessionData.AccountDID.String()
// Resolve handle from DID
_, handle, _, err := atproto.ResolveIdentity(ctx, did)
if err != nil {
slog.Warn("Failed to resolve handle from DID", "did", did, "error", err)
handle = did // Fallback to DID
}
slog.Info("OAuth callback successful", "did", did, "handle", handle)
// Get captain record to check owner
_, captain, err := ui.pds.GetCaptainRecord(ctx)
if err != nil {
slog.Error("Failed to get captain record during OAuth callback", "error", err)
http.Redirect(w, r, "/admin/auth/login?error=ownership_check_failed", http.StatusFound)
return
}
// CRITICAL: Only allow the hold owner
if did != captain.Owner {
slog.Warn("Non-owner attempted admin access",
"did", did,
"handle", handle,
"owner", captain.Owner)
http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound)
return
}
// Create session and set cookie. Bind to the browser's User-Agent and
// network prefix at login so a stolen cookie replayed from a different
// browser or /24 can't be used.
token, err := ui.createSession(did, handle, r.UserAgent(), clientIPPrefix(r))
if err != nil {
slog.Error("failed to create session token", "error", err, "path", r.URL.Path)
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
}
ui.setSessionCookie(w, r, token)
slog.Info("Admin login successful", "did", did, "handle", handle)
http.Redirect(w, r, "/admin", http.StatusFound)
}
// handleLogout clears the session and redirects to login
func (ui *AdminUI) handleLogout(w http.ResponseWriter, r *http.Request) {
if token, ok := getSessionCookie(r); ok {
ui.deleteSession(token)
}
clearSessionCookie(w)
http.Redirect(w, r, "/admin/auth/login", http.StatusFound)
}