mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-21 09:44:15 +00:00
158 lines
5.0 KiB
Go
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)
|
|
}
|