diff options
| author | Chia <Chia@93.nz> | 2026-08-05 09:26:05 +1200 |
|---|---|---|
| committer | Chia <Chia@93.nz> | 2026-08-05 09:26:05 +1200 |
| commit | 86b1f42e3c5601ff10621a9779cf0076590797a1 (patch) | |
| tree | ccf584f0404dece2e4dae2eee847b665f57c0737 /internal/adminapi | |
| parent | 1a3d7f9a8a181df48f0e911cbe17a3fad3ab9ac9 (diff) | |
add sth.
Diffstat (limited to '')
| -rw-r--r-- | internal/adminapi/api.go | 269 |
1 files changed, 242 insertions, 27 deletions
diff --git a/internal/adminapi/api.go b/internal/adminapi/api.go index 7f5f8bd..adbaea8 100644 --- a/internal/adminapi/api.go +++ b/internal/adminapi/api.go @@ -3,6 +3,7 @@ package adminapi import ( "context" "crypto/rand" + "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" @@ -22,12 +23,15 @@ import ( ) type API struct { - store *controlplane.Store - manager *controlplane.Manager - billing *billing.Service - token []byte - logger *slog.Logger - prefix string + store *controlplane.Store + manager *controlplane.Manager + billing *billing.Service + token []byte + logger *slog.Logger + prefix string + registrationEnabled bool + sessionTTL time.Duration + currency string } type actorKey struct{} @@ -48,12 +52,15 @@ func (w *auditWriter) Write(body []byte) (int, error) { } type Options struct { - Store *controlplane.Store - Manager *controlplane.Manager - Billing *billing.Service - Token string - Logger *slog.Logger - Prefix string + Store *controlplane.Store + Manager *controlplane.Manager + Billing *billing.Service + Token string + Logger *slog.Logger + Prefix string + RegistrationEnabled bool + SessionTTL time.Duration + Currency string } func New(options Options) *API { @@ -61,7 +68,18 @@ func New(options Options) *API { if prefix == "" { prefix = "/admin" } - return &API{store: options.Store, manager: options.Manager, billing: options.Billing, token: []byte(options.Token), logger: options.Logger, prefix: prefix} + if options.Logger == nil { + options.Logger = slog.Default() + } + if options.SessionTTL <= 0 { + options.SessionTTL = 12 * time.Hour + } + if options.Currency == "" { + options.Currency = "usd" + } + return &API{store: options.Store, manager: options.Manager, billing: options.Billing, token: []byte(options.Token), + logger: options.Logger, prefix: prefix, registrationEnabled: options.RegistrationEnabled, + sessionTTL: options.SessionTTL, currency: options.Currency} } func (a *API) Handler() http.Handler { @@ -71,6 +89,12 @@ func (a *API) Handler() http.Handler { http.Redirect(w, r, a.prefix+"/", http.StatusTemporaryRedirect) }) mux.Handle(a.prefix+"/", http.StripPrefix(a.prefix, adminui.Handler())) + mux.HandleFunc("GET "+apiPrefix+"/auth/config", a.public(a.authConfig)) + mux.HandleFunc("GET "+apiPrefix+"/auth/session", a.public(a.authSession)) + mux.HandleFunc("POST "+apiPrefix+"/auth/register", a.public(a.register)) + mux.HandleFunc("POST "+apiPrefix+"/auth/login", a.public(a.login)) + mux.HandleFunc("POST "+apiPrefix+"/auth/logout", a.withAuth("overview.read", a.logout)) + mux.HandleFunc("POST "+apiPrefix+"/auth/password", a.withAuth("overview.read", a.changePassword)) mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth("overview.read", a.overview)) mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth("tenants.read", a.listTenants)) @@ -102,34 +126,67 @@ func (a *API) Handler() http.Handler { mux.HandleFunc("POST "+apiPrefix+"/users/{id}/revoke", a.withAuth("users.write", a.revokeUser)) mux.HandleFunc("GET "+apiPrefix+"/audit", a.withAuth("audit.read", a.listAudit)) mux.HandleFunc("GET "+apiPrefix+"/me", a.withAuth("overview.read", a.me)) - return mux + return a.securityHeaders(mux) } -func (a *API) withAuth(permission string, next http.HandlerFunc) http.HandlerFunc { +func (a *API) public(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-AIGW-Request-ID") == "" { - r.Header.Set("X-AIGW-Request-ID", adminRequestID(r)) + a.prepareRequest(w, r) + next(w, r) + } +} + +func (a *API) prepareRequest(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-AIGW-Request-ID") == "" { + r.Header.Set("X-AIGW-Request-ID", adminRequestID(r)) + } + w.Header().Set("X-AIGW-Request-ID", r.Header.Get("X-AIGW-Request-ID")) +} + +func (a *API) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + if strings.Contains(r.URL.Path, "/api/") { + w.Header().Set("Cache-Control", "no-store") } - w.Header().Set("X-AIGW-Request-ID", r.Header.Get("X-AIGW-Request-ID")) + next.ServeHTTP(w, r) + }) +} + +func (a *API) withAuth(permission string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + a.prepareRequest(w, r) provided := strings.TrimSpace(r.Header.Get("X-Admin-Token")) if provided == "" { provided = bearerToken(r.Header.Get("Authorization")) } actor := controlplane.ConsoleActor{} + var authErr error if len(provided) > 0 && len(a.token) > 0 && subtle.ConstantTimeCompare([]byte(provided), a.token) == 1 { actor = controlplane.ConsoleActor{Role: controlplane.RolePlatformAdmin, DisplayName: "Bootstrap administrator", Bootstrap: true} } else if provided != "" && a.store != nil { - var err error - actor, err = a.store.AuthenticateConsoleToken(r.Context(), provided) - if err != nil && !errors.Is(err, controlplane.ErrConsoleUnauthorized) { - a.logger.Error("console_authentication_failed", "error", err) - apierror.Write(w, apierror.Error{Status: http.StatusServiceUnavailable, Type: "control_plane_unavailable", Message: "Control plane authentication is temporarily unavailable"}, requestID(r)) - return - } - if err != nil { - actor = controlplane.ConsoleActor{} + actor, authErr = a.store.AuthenticateConsoleToken(r.Context(), provided) + } else if cookie, err := r.Cookie("aigw_session"); err == nil && a.store != nil { + var csrfHash []byte + actor, csrfHash, authErr = a.store.AuthenticateConsoleSession(r.Context(), cookie.Value) + if authErr == nil && isUnsafeMethod(r.Method) { + providedCSRF := strings.TrimSpace(r.Header.Get("X-CSRF-Token")) + actualCSRF := sha256.Sum256([]byte(providedCSRF)) + if providedCSRF == "" || subtle.ConstantTimeCompare(actualCSRF[:], csrfHash) != 1 { + apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "csrf_failed", Message: "Request verification failed; reload and try again"}, requestID(r)) + return + } } } + if authErr != nil && !errors.Is(authErr, controlplane.ErrConsoleUnauthorized) { + a.logger.Error("console_authentication_failed", "error", authErr) + apierror.Write(w, apierror.Error{Status: http.StatusServiceUnavailable, Type: "control_plane_unavailable", Message: "Control plane authentication is temporarily unavailable"}, requestID(r)) + return + } if actor.Role == "" { apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "admin_unauthorized", Message: "Administrator authentication required"}, requestID(r)) return @@ -181,6 +238,164 @@ func adminRequestID(r *http.Request) string { return "adm_unknown" } +func (a *API) authConfig(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]any{"registration_enabled": a.registrationEnabled}) +} + +func (a *API) authSession(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("aigw_session") + if err != nil { + writeJSON(w, map[string]any{"authenticated": false}) + return + } + actor, _, err := a.store.AuthenticateConsoleSession(r.Context(), cookie.Value) + if errors.Is(err, controlplane.ErrConsoleUnauthorized) { + a.clearSessionCookie(w, r) + writeJSON(w, map[string]any{"authenticated": false}) + return + } + if err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, map[string]any{"authenticated": true, "actor": actor, "permissions": actor.Permissions()}) +} + +func (a *API) register(w http.ResponseWriter, r *http.Request) { + if !a.registrationEnabled { + apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "registration_disabled", Message: "New account registration is disabled"}, requestID(r)) + return + } + var input controlplane.RegisterInput + if !decodeBody(w, r, &input) { + return + } + actor, generation, err := a.store.RegisterTenant(r.Context(), input, a.currency) + if err != nil { + a.writeAudit(r, controlplane.ConsoleActor{}, "auth.register", http.StatusBadRequest) + a.mutationError(w, r, err) + return + } + if a.manager != nil { + if err := a.manager.AfterMutation(r.Context(), generation, "tenant", actor.TenantID); err != nil { + a.logger.Warn("registration_snapshot_reload_failed", "tenant_id", actor.TenantID, "error", err) + } + } + session, err := a.store.CreateConsoleSession(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent()) + if err != nil { + a.databaseError(w, r, err) + return + } + a.setSessionCookie(w, r, session) + a.writeAudit(r, actor, "auth.register", http.StatusCreated) + writeStatusJSON(w, http.StatusCreated, sessionPayload(session)) +} + +func (a *API) login(w http.ResponseWriter, r *http.Request) { + var input controlplane.PasswordLoginInput + if !decodeBody(w, r, &input) { + return + } + actor, err := a.store.AuthenticateConsolePassword(r.Context(), input, remoteIP(r)) + if err != nil { + status := http.StatusUnauthorized + typeName := "invalid_credentials" + message := "Email or password is incorrect" + if errors.Is(err, controlplane.ErrConsoleRateLimited) { + status = http.StatusTooManyRequests + typeName = "login_rate_limited" + message = "Too many login attempts; try again in 15 minutes" + w.Header().Set("Retry-After", "900") + } else if !errors.Is(err, controlplane.ErrConsoleUnauthorized) { + status = http.StatusServiceUnavailable + typeName = "control_plane_unavailable" + message = "Login is temporarily unavailable" + a.logger.Error("console_password_login_failed", "error", err) + } + a.writeAudit(r, controlplane.ConsoleActor{}, "auth.login", status) + apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r)) + return + } + session, err := a.store.CreateConsoleSession(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent()) + if err != nil { + a.databaseError(w, r, err) + return + } + a.setSessionCookie(w, r, session) + a.writeAudit(r, actor, "auth.login", http.StatusOK) + writeJSON(w, sessionPayload(session)) +} + +func (a *API) logout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("aigw_session"); err == nil { + if err := a.store.RevokeConsoleSession(r.Context(), cookie.Value); err != nil { + a.databaseError(w, r, err) + return + } + } + a.clearSessionCookie(w, r) + writeJSON(w, map[string]any{"status": "signed_out"}) +} + +func (a *API) changePassword(w http.ResponseWriter, r *http.Request) { + actor := a.actor(r) + if actor.ID == "" { + apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "bootstrap_account", Message: "Bootstrap access does not have a password"}, requestID(r)) + return + } + var input controlplane.PasswordChangeInput + if !decodeBody(w, r, &input) { + return + } + if err := a.store.ChangeConsolePassword(r.Context(), actor.ID, input); err != nil { + if errors.Is(err, controlplane.ErrConsoleUnauthorized) { + apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r)) + return + } + a.mutationError(w, r, err) + return + } + a.clearSessionCookie(w, r) + writeJSON(w, map[string]any{"status": "password_changed", "reauthentication_required": true}) +} + +func sessionPayload(session controlplane.ConsoleSession) map[string]any { + return map[string]any{ + "actor": session.Actor, "permissions": session.Actor.Permissions(), + "csrf_token": session.CSRFToken, "expires_at": session.ExpiresAt, + } +} + +func (a *API) setSessionCookie(w http.ResponseWriter, r *http.Request, session controlplane.ConsoleSession) { + maxAge := int(time.Until(session.ExpiresAt).Seconds()) + http.SetCookie(w, &http.Cookie{Name: "aigw_session", Value: session.Token, Path: a.prefix + "/", MaxAge: maxAge, + Expires: session.ExpiresAt, HttpOnly: true, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) + http.SetCookie(w, &http.Cookie{Name: "aigw_csrf", Value: session.CSRFToken, Path: a.prefix + "/", MaxAge: maxAge, + Expires: session.ExpiresAt, HttpOnly: false, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) +} + +func (a *API) clearSessionCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "aigw_session", Value: "", Path: a.prefix + "/", MaxAge: -1, + Expires: time.Unix(1, 0), HttpOnly: true, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) + http.SetCookie(w, &http.Cookie{Name: "aigw_csrf", Value: "", Path: a.prefix + "/", MaxAge: -1, + Expires: time.Unix(1, 0), HttpOnly: false, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) +} + +func requestIsHTTPS(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") +} + +func isUnsafeMethod(method string) bool { + return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions +} + +func remoteIP(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + func (a *API) overview(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) tenantID := actor.TenantID |
