summaryrefslogtreecommitdiff
path: root/internal/adminapi/api.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/adminapi/api.go')
-rw-r--r--internal/adminapi/api.go269
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