diff options
| author | Chia <Chia@93.nz> | 2026-08-05 14:48:00 +1200 |
|---|---|---|
| committer | Chia <Chia@93.nz> | 2026-08-05 14:48:00 +1200 |
| commit | cd0dd91ab93653631904f2ea0e574ccde6d60339 (patch) | |
| tree | c65417b880a3f4a35c504c44edae821bc2122f70 /internal | |
| parent | 86b1f42e3c5601ff10621a9779cf0076590797a1 (diff) | |
add passkey, totp.
Diffstat (limited to '')
| -rw-r--r-- | internal/adminapi/api.go | 566 | ||||
| -rw-r--r-- | internal/adminui/assets/app.js | 92 | ||||
| -rw-r--r-- | internal/adminui/assets/index.html | 43 | ||||
| -rw-r--r-- | internal/adminui/assets/style.css | 7 | ||||
| -rw-r--r-- | internal/billing/ledger.go | 38 | ||||
| -rw-r--r-- | internal/billing/service_test.go | 12 | ||||
| -rw-r--r-- | internal/billing/stripe.go | 23 | ||||
| -rw-r--r-- | internal/billing/types.go | 14 | ||||
| -rw-r--r-- | internal/config/config.go | 171 | ||||
| -rw-r--r-- | internal/config/config_test.go | 80 | ||||
| -rw-r--r-- | internal/controlplane/access.go | 114 | ||||
| -rw-r--r-- | internal/controlplane/identity.go | 291 | ||||
| -rw-r--r-- | internal/controlplane/mfa.go | 344 | ||||
| -rw-r--r-- | internal/controlplane/outbox.go | 152 | ||||
| -rw-r--r-- | internal/controlplane/outbox_test.go | 33 | ||||
| -rw-r--r-- | internal/controlplane/passkeys.go | 294 | ||||
| -rw-r--r-- | internal/controlplane/passkeys_test.go | 27 | ||||
| -rw-r--r-- | internal/controlplane/schema.sql | 113 | ||||
| -rw-r--r-- | internal/controlplane/types.go | 104 | ||||
| -rw-r--r-- | internal/mailer/mailer.go | 170 |
20 files changed, 2595 insertions, 93 deletions
diff --git a/internal/adminapi/api.go b/internal/adminapi/api.go index adbaea8..4e66d7f 100644 --- a/internal/adminapi/api.go +++ b/internal/adminapi/api.go @@ -1,6 +1,7 @@ package adminapi import ( + "bytes" "context" "crypto/rand" "crypto/sha256" @@ -8,6 +9,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "io" "log/slog" "net" "net/http" @@ -19,6 +21,7 @@ import ( "aigw/internal/billing" "aigw/internal/controlplane" + "github.com/go-webauthn/webauthn/webauthn" "github.com/jackc/pgx/v5/pgconn" ) @@ -32,6 +35,9 @@ type API struct { registrationEnabled bool sessionTTL time.Duration currency string + publicURL string + webauthn *webauthn.WebAuthn + mailEnabled bool } type actorKey struct{} @@ -61,6 +67,9 @@ type Options struct { RegistrationEnabled bool SessionTTL time.Duration Currency string + PublicURL string + WebAuthn *webauthn.WebAuthn + MailEnabled bool } func New(options Options) *API { @@ -79,22 +88,47 @@ func New(options Options) *API { } 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} + sessionTTL: options.SessionTTL, currency: options.Currency, publicURL: strings.TrimRight(options.PublicURL, "/") + "/", + webauthn: options.WebAuthn, mailEnabled: options.MailEnabled} } func (a *API) Handler() http.Handler { mux := http.NewServeMux() apiPrefix := a.prefix + "/api" mux.HandleFunc("GET "+a.prefix, func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, a.prefix+"/", http.StatusTemporaryRedirect) + target := a.prefix + "/" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, 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/email/verify", a.public(a.verifyEmail)) + mux.HandleFunc("POST "+apiPrefix+"/auth/email/resend", a.public(a.resendVerification)) + mux.HandleFunc("POST "+apiPrefix+"/auth/password-reset/request", a.public(a.requestPasswordReset)) + mux.HandleFunc("POST "+apiPrefix+"/auth/password-reset/complete", a.public(a.completePasswordReset)) + mux.HandleFunc("POST "+apiPrefix+"/auth/invite/accept", a.public(a.acceptInvite)) + mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp", a.public(a.completeMFA)) + mux.HandleFunc("POST "+apiPrefix+"/auth/passkey/login/options", a.public(a.passkeyLoginOptions)) + mux.HandleFunc("POST "+apiPrefix+"/auth/passkey/login", a.public(a.passkeyLogin)) + mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey/login/options", a.public(a.beginPasskeyMFA)) + mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey/login", a.public(a.finishPasskeyMFA)) 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+"/auth/mfa", a.withAuth("overview.read", a.mfaStatus)) + mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp/begin", a.withAuth("overview.read", a.beginTOTP)) + mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp/confirm", a.withAuth("overview.read", a.confirmTOTP)) + mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp/disable", a.withAuth("overview.read", a.disableTOTP)) + mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey/options", a.withAuth("overview.read", a.beginPasskeyRegistration)) + mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey", a.withAuth("overview.read", a.finishPasskeyRegistration)) + mux.HandleFunc("GET "+apiPrefix+"/auth/sessions", a.withAuth("overview.read", a.listSessions)) + mux.HandleFunc("POST "+apiPrefix+"/auth/sessions/{id}/revoke", a.withAuth("overview.read", a.revokeSession)) + mux.HandleFunc("POST "+apiPrefix+"/auth/sessions/revoke-others", a.withAuth("overview.read", a.revokeOtherSessions)) + mux.HandleFunc("POST "+apiPrefix+"/auth/passkeys/{id}/delete", a.withAuth("overview.read", a.deletePasskey)) mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth("overview.read", a.overview)) mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth("tenants.read", a.listTenants)) @@ -114,6 +148,8 @@ func (a *API) Handler() http.Handler { if a.billing != nil { mux.HandleFunc("GET "+apiPrefix+"/billing/accounts", a.withAuth("billing.read", a.listBillingAccounts)) mux.HandleFunc("GET "+apiPrefix+"/billing/ledger", a.withAuth("billing.read", a.listBillingLedger)) + mux.HandleFunc("GET "+apiPrefix+"/billing/orders", a.withAuth("billing.read", a.listTopUpOrders)) + mux.HandleFunc("GET "+apiPrefix+"/billing/orders/{id}", a.withAuth("billing.read", a.getTopUpOrder)) mux.HandleFunc("POST "+apiPrefix+"/billing/adjustments", a.withAuth("billing.adjust", a.adjustBalance)) mux.HandleFunc("POST "+apiPrefix+"/billing/checkout-sessions", a.withAuth("billing.topup", a.createCheckoutSession)) } @@ -149,7 +185,7 @@ func (a *API) securityHeaders(next http.Handler) http.Handler { 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=()") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), publickey-credentials-create=(self), publickey-credentials-get=(self)") if strings.Contains(r.URL.Path, "/api/") { w.Header().Set("Cache-Control", "no-store") } @@ -239,7 +275,8 @@ func adminRequestID(r *http.Request) string { } func (a *API) authConfig(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, map[string]any{"registration_enabled": a.registrationEnabled}) + writeJSON(w, map[string]any{"registration_enabled": a.registrationEnabled, "email_delivery_enabled": a.mailEnabled, + "passkeys_enabled": a.webauthn != nil}) } func (a *API) authSession(w http.ResponseWriter, r *http.Request) { @@ -270,7 +307,11 @@ func (a *API) register(w http.ResponseWriter, r *http.Request) { if !decodeBody(w, r, &input) { return } - actor, generation, err := a.store.RegisterTenant(r.Context(), input, a.currency) + if !a.consumePublicRateLimit(w, r, "register_ip", remoteIP(r), 5, time.Hour) || + !a.consumePublicRateLimit(w, r, "register_email", input.Email, 3, 24*time.Hour) { + return + } + actor, generation, err := a.store.RegisterTenantPending(r.Context(), input, a.currency, a.publicURL, remoteIP(r)) if err != nil { a.writeAudit(r, controlplane.ConsoleActor{}, "auth.register", http.StatusBadRequest) a.mutationError(w, r, err) @@ -281,14 +322,9 @@ func (a *API) register(w http.ResponseWriter, r *http.Request) { 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)) + writeStatusJSON(w, http.StatusCreated, map[string]any{"status": "verification_required", "email": actor.Email, + "delivery": map[bool]string{true: "queued", false: "pending_configuration"}[a.mailEnabled]}) } func (a *API) login(w http.ResponseWriter, r *http.Request) { @@ -316,7 +352,18 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) { 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 len(actor.MFAMethods) > 0 { + challenge, err := a.store.BeginMFAChallenge(r.Context(), actor, remoteIP(r), r.UserAgent()) + if err != nil { + a.databaseError(w, r, err) + return + } + a.writeAudit(r, actor, "auth.login.password", http.StatusAccepted) + writeStatusJSON(w, http.StatusAccepted, map[string]any{"mfa_required": true, "challenge_token": challenge.Token, + "methods": challenge.Methods, "expires_at": challenge.ExpiresAt}) + return + } + session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "password", false) if err != nil { a.databaseError(w, r, err) return @@ -326,6 +373,218 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) { writeJSON(w, sessionPayload(session)) } +func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) { + var input struct { + Token string `json:"token"` + } + if !decodeBody(w, r, &input) { + return + } + actor, err := a.store.VerifyEmailToken(r.Context(), input.Token) + if err != nil { + a.actionError(w, r, err) + return + } + session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "email_link", false) + if err != nil { + a.databaseError(w, r, err) + return + } + a.setSessionCookie(w, r, session) + a.writeAudit(r, actor, "auth.email.verify", http.StatusOK) + writeJSON(w, sessionPayload(session)) +} + +func (a *API) resendVerification(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + } + if !decodeBody(w, r, &input) { + return + } + if !a.consumePublicRateLimit(w, r, "verify_resend_ip", remoteIP(r), 10, time.Hour) || + !a.consumePublicRateLimit(w, r, "verify_resend_email", input.Email, 3, time.Hour) { + return + } + if err := a.store.ResendVerification(r.Context(), input.Email, a.publicURL, remoteIP(r)); err != nil { + a.databaseError(w, r, err) + return + } + writeStatusJSON(w, http.StatusAccepted, map[string]any{"status": "queued"}) +} + +func (a *API) requestPasswordReset(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + } + if !decodeBody(w, r, &input) { + return + } + if !a.consumePublicRateLimit(w, r, "password_reset_ip", remoteIP(r), 20, time.Hour) || + !a.consumePublicRateLimit(w, r, "password_reset_email", input.Email, 3, time.Hour) { + return + } + if err := a.store.RequestPasswordReset(r.Context(), input.Email, a.publicURL, remoteIP(r)); err != nil { + a.databaseError(w, r, err) + return + } + writeStatusJSON(w, http.StatusAccepted, map[string]any{"status": "queued"}) +} + +func (a *API) completePasswordReset(w http.ResponseWriter, r *http.Request) { + var input controlplane.PasswordResetInput + if !decodeBody(w, r, &input) { + return + } + if err := a.store.ResetPassword(r.Context(), input); err != nil { + a.actionError(w, r, err) + return + } + writeJSON(w, map[string]any{"status": "password_reset", "reauthentication_required": true}) +} + +func (a *API) acceptInvite(w http.ResponseWriter, r *http.Request) { + var input controlplane.InviteAcceptInput + if !decodeBody(w, r, &input) { + return + } + actor, err := a.store.AcceptInvite(r.Context(), input) + if err != nil { + a.actionError(w, r, err) + return + } + session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "invite_link", false) + if err != nil { + a.databaseError(w, r, err) + return + } + a.setSessionCookie(w, r, session) + a.writeAudit(r, actor, "auth.invite.accept", http.StatusOK) + writeJSON(w, sessionPayload(session)) +} + +func (a *API) completeMFA(w http.ResponseWriter, r *http.Request) { + var input controlplane.MFACodeInput + if !decodeBody(w, r, &input) { + return + } + actor, method, err := a.store.CompleteMFAChallenge(r.Context(), input, remoteIP(r)) + if err != nil { + a.actionError(w, r, err) + return + } + session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "password+"+method, true) + if err != nil { + a.databaseError(w, r, err) + return + } + a.setSessionCookie(w, r, session) + a.writeAudit(r, actor, "auth.login."+method, http.StatusOK) + writeJSON(w, sessionPayload(session)) +} + +func (a *API) passkeyLoginOptions(w http.ResponseWriter, r *http.Request) { + if !a.requireWebAuthn(w, r) { + return + } + var input struct { + Email string `json:"email"` + } + if !decodeBody(w, r, &input) { + return + } + if !a.consumePublicRateLimit(w, r, "passkey_login_ip", remoteIP(r), 30, 15*time.Minute) || + !a.consumePublicRateLimit(w, r, "passkey_login_email", input.Email, 10, 15*time.Minute) { + return + } + actor, err := a.store.FindActiveConsoleUser(r.Context(), input.Email) + if err != nil { + a.passkeyAuthError(w, r, err) + return + } + token, options, err := a.store.BeginWebAuthnLogin(r.Context(), actor.ID, "login", "", a.webauthn) + if err != nil { + a.passkeyAuthError(w, r, err) + return + } + writeJSON(w, map[string]any{"challenge_token": token, "options": options}) +} + +func (a *API) passkeyLogin(w http.ResponseWriter, r *http.Request) { + a.finishPasskeyLogin(w, r, "login", "passkey") +} + +func (a *API) beginPasskeyMFA(w http.ResponseWriter, r *http.Request) { + if !a.requireWebAuthn(w, r) { + return + } + var input struct { + ChallengeToken string `json:"challenge_token"` + } + if !decodeBody(w, r, &input) { + return + } + authChallengeID, userID, err := a.store.ResolveMFAChallenge(r.Context(), input.ChallengeToken, remoteIP(r)) + if err != nil { + a.actionError(w, r, err) + return + } + token, options, err := a.store.BeginWebAuthnLogin(r.Context(), userID, "mfa_login", authChallengeID, a.webauthn) + if err != nil { + a.passkeyAuthError(w, r, err) + return + } + writeJSON(w, map[string]any{"challenge_token": token, "options": options}) +} + +func (a *API) finishPasskeyMFA(w http.ResponseWriter, r *http.Request) { + a.finishPasskeyLogin(w, r, "mfa_login", "password+passkey") +} + +type webAuthnFinishInput struct { + ChallengeToken string `json:"challenge_token"` + Name string `json:"name"` + Credential json.RawMessage `json:"credential"` +} + +func (a *API) finishPasskeyLogin(w http.ResponseWriter, r *http.Request, purpose, authMethod string) { + if !a.requireWebAuthn(w, r) { + return + } + var input webAuthnFinishInput + if !decodeBody(w, r, &input) { + return + } + userID, authChallengeID, sessionData, err := a.store.WebAuthnSession(r.Context(), input.ChallengeToken, purpose) + if err != nil { + a.actionError(w, r, err) + return + } + user, err := a.store.WebAuthnUser(r.Context(), userID) + if err != nil { + a.passkeyAuthError(w, r, err) + return + } + credential, err := a.webauthn.FinishLogin(user, sessionData, credentialRequest(r, input.Credential)) + if err != nil { + a.passkeyAuthError(w, r, err) + return + } + actor, err := a.store.FinishWebAuthnLogin(r.Context(), input.ChallengeToken, userID, authChallengeID, credential, purpose) + if err != nil { + a.passkeyAuthError(w, r, err) + return + } + session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), authMethod, true) + if err != nil { + a.databaseError(w, r, err) + return + } + a.setSessionCookie(w, r, session) + a.writeAudit(r, actor, "auth.login.passkey", 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 { @@ -359,10 +618,201 @@ func (a *API) changePassword(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"status": "password_changed", "reauthentication_required": true}) } +func (a *API) mfaStatus(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 has no MFA profile"}, requestID(r)) + return + } + result, err := a.store.MFAStatus(r.Context(), actor.ID) + if err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, result) +} + +func (a *API) beginTOTP(w http.ResponseWriter, r *http.Request) { + actor := a.actor(r) + var input struct { + CurrentPassword string `json:"current_password"` + } + if !decodeBody(w, r, &input) { + return + } + if actor.ID == "" || a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil { + apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r)) + return + } + result, err := a.store.BeginTOTP(r.Context(), actor) + if err != nil { + a.mutationError(w, r, err) + return + } + writeJSON(w, result) +} + +func (a *API) confirmTOTP(w http.ResponseWriter, r *http.Request) { + var input struct { + Code string `json:"code"` + } + if !decodeBody(w, r, &input) { + return + } + codes, err := a.store.ConfirmTOTP(r.Context(), a.actor(r).ID, input.Code) + if err != nil { + a.actionError(w, r, err) + return + } + writeJSON(w, map[string]any{"status": "enabled", "recovery_codes": codes}) +} + +func (a *API) disableTOTP(w http.ResponseWriter, r *http.Request) { + actor := a.actor(r) + var input struct { + CurrentPassword string `json:"current_password"` + Code string `json:"code"` + } + if !decodeBody(w, r, &input) { + return + } + if a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil || + a.store.VerifyTOTPForUser(r.Context(), actor.ID, input.Code) != nil { + apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Password or authenticator code is incorrect"}, requestID(r)) + return + } + if err := a.store.DisableTOTP(r.Context(), actor.ID); err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, map[string]any{"status": "disabled"}) +} + +func (a *API) beginPasskeyRegistration(w http.ResponseWriter, r *http.Request) { + if !a.requireWebAuthn(w, r) { + return + } + actor := a.actor(r) + var input struct { + CurrentPassword string `json:"current_password"` + } + if !decodeBody(w, r, &input) { + return + } + if actor.ID == "" || a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil { + apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r)) + return + } + token, options, err := a.store.BeginWebAuthnRegistration(r.Context(), actor.ID, a.webauthn) + if err != nil { + a.passkeyAuthError(w, r, err) + return + } + writeJSON(w, map[string]any{"challenge_token": token, "options": options}) +} + +func (a *API) finishPasskeyRegistration(w http.ResponseWriter, r *http.Request) { + if !a.requireWebAuthn(w, r) { + return + } + var input webAuthnFinishInput + if !decodeBody(w, r, &input) { + return + } + actor := a.actor(r) + userID, _, sessionData, err := a.store.WebAuthnSession(r.Context(), input.ChallengeToken, "register") + if err != nil || userID != actor.ID { + a.actionError(w, r, controlplane.ErrActionTokenInvalid) + return + } + user, err := a.store.WebAuthnUser(r.Context(), actor.ID) + if err != nil { + a.passkeyAuthError(w, r, err) + return + } + credential, err := a.webauthn.FinishRegistration(user, sessionData, credentialRequest(r, input.Credential)) + if err != nil { + a.passkeyAuthError(w, r, err) + return + } + if _, err := a.store.FinishWebAuthnRegistration(r.Context(), input.ChallengeToken, actor.ID, input.Name, credential); err != nil { + a.passkeyAuthError(w, r, err) + return + } + writeStatusJSON(w, http.StatusCreated, map[string]any{"status": "registered"}) +} + +func (a *API) deletePasskey(w http.ResponseWriter, r *http.Request) { + actor := a.actor(r) + var input struct { + CurrentPassword string `json:"current_password"` + } + if !decodeBody(w, r, &input) { + return + } + if a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil { + apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r)) + return + } + if err := a.store.DeletePasskey(r.Context(), actor.ID, r.PathValue("id")); err != nil { + a.mutationError(w, r, err) + return + } + writeJSON(w, map[string]any{"status": "deleted"}) +} + +func (a *API) listSessions(w http.ResponseWriter, r *http.Request) { + actor := a.actor(r) + if actor.ID == "" { + writeJSON(w, []controlplane.DeviceSession{}) + return + } + result, err := a.store.ListDeviceSessions(r.Context(), actor.ID, sessionCookie(r)) + if err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, result) +} + +func (a *API) revokeSession(w http.ResponseWriter, r *http.Request) { + actor := a.actor(r) + current := sessionCookie(r) + sessions, err := a.store.ListDeviceSessions(r.Context(), actor.ID, current) + if err != nil { + a.databaseError(w, r, err) + return + } + currentRevoked := false + for _, session := range sessions { + if session.ID == r.PathValue("id") { + currentRevoked = session.Current + break + } + } + if _, err := a.store.RevokeDeviceSession(r.Context(), actor.ID, r.PathValue("id")); err != nil { + a.mutationError(w, r, err) + return + } + if currentRevoked { + a.clearSessionCookie(w, r) + } + writeJSON(w, map[string]any{"status": "revoked", "current": currentRevoked}) +} + +func (a *API) revokeOtherSessions(w http.ResponseWriter, r *http.Request) { + if err := a.store.RevokeOtherDeviceSessions(r.Context(), a.actor(r).ID, sessionCookie(r)); err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, map[string]any{"status": "revoked"}) +} + 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, + "session_id": session.ID, "auth_method": session.AuthMethod, } } @@ -385,6 +835,62 @@ func requestIsHTTPS(r *http.Request) bool { return r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") } +func sessionCookie(r *http.Request) string { + if cookie, err := r.Cookie("aigw_session"); err == nil { + return cookie.Value + } + return "" +} + +func credentialRequest(r *http.Request, credential json.RawMessage) *http.Request { + request := r.Clone(r.Context()) + request.Body = io.NopCloser(bytes.NewReader(credential)) + request.ContentLength = int64(len(credential)) + request.Header = r.Header.Clone() + request.Header.Set("Content-Type", "application/json") + return request +} + +func (a *API) requireWebAuthn(w http.ResponseWriter, r *http.Request) bool { + if a.webauthn != nil { + return true + } + apierror.Write(w, apierror.Error{Status: http.StatusNotImplemented, Type: "passkeys_disabled", Message: "Passkeys are not configured for this console"}, requestID(r)) + return false +} + +func (a *API) consumePublicRateLimit(w http.ResponseWriter, r *http.Request, scope, key string, limit int, window time.Duration) bool { + if err := a.store.ConsumeRateLimit(r.Context(), scope, key, limit, window); err != nil { + if errors.Is(err, controlplane.ErrConsoleRateLimited) { + w.Header().Set("Retry-After", "900") + apierror.Write(w, apierror.Error{Status: http.StatusTooManyRequests, Type: "rate_limited", Message: "Too many requests; try again later"}, requestID(r)) + return false + } + a.databaseError(w, r, err) + return false + } + return true +} + +func (a *API) actionError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, controlplane.ErrActionTokenInvalid) { + apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "invalid_or_expired_token", Message: "This link or authentication challenge is invalid or expired"}, requestID(r)) + return + } + if errors.Is(err, controlplane.ErrConsoleUnauthorized) { + apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_authenticator", Message: "The authentication code is incorrect"}, requestID(r)) + return + } + a.mutationError(w, r, err) +} + +func (a *API) passkeyAuthError(w http.ResponseWriter, r *http.Request, err error) { + if !errors.Is(err, controlplane.ErrConsoleUnauthorized) && !errors.Is(err, controlplane.ErrNoPasskeys) && !errors.Is(err, controlplane.ErrActionTokenInvalid) { + a.logger.Warn("passkey_authentication_failed", "error", err) + } + apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "passkey_failed", Message: "Passkey authentication could not be completed"}, requestID(r)) +} + func isUnsafeMethod(method string) bool { return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions } @@ -437,6 +943,34 @@ func (a *API) listBillingLedger(w http.ResponseWriter, r *http.Request) { writeJSON(w, result) } +func (a *API) listTopUpOrders(w http.ResponseWriter, r *http.Request) { + actor := a.actor(r) + if actor.TenantID == "" { + apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "tenant_required", Message: "A tenant is required"}, requestID(r)) + return + } + result, err := a.billing.ListTopUpOrders(r.Context(), actor.TenantID, 50) + if err != nil { + a.billingError(w, r, err) + return + } + writeJSON(w, result) +} + +func (a *API) getTopUpOrder(w http.ResponseWriter, r *http.Request) { + actor := a.actor(r) + if actor.TenantID == "" { + apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "tenant_required", Message: "A tenant is required"}, requestID(r)) + return + } + result, err := a.billing.GetTopUpOrder(r.Context(), actor.TenantID, r.PathValue("id")) + if err != nil { + a.billingError(w, r, err) + return + } + writeJSON(w, result) +} + func (a *API) adjustBalance(w http.ResponseWriter, r *http.Request) { var input billing.AdjustmentInput if !decodeBody(w, r, &input) { @@ -730,7 +1264,7 @@ func (a *API) createUser(w http.ResponseWriter, r *http.Request) { return } } - result, err := a.store.CreateConsoleUser(r.Context(), input) + result, err := a.store.InviteConsoleUser(r.Context(), input, a.publicURL, remoteIP(r)) if err != nil { a.mutationError(w, r, err) return @@ -837,6 +1371,10 @@ func (a *API) billingError(w http.ResponseWriter, r *http.Request, err error) { status = http.StatusServiceUnavailable typeName = "stripe_disabled" message = "Stripe top-ups are disabled" + case errors.Is(err, billing.ErrTopUpOrderNotFound): + status = http.StatusNotFound + typeName = "topup_order_not_found" + message = "Top-up order was not found" default: a.logger.Error("admin_billing_error", "error", err) } diff --git a/internal/adminui/assets/app.js b/internal/adminui/assets/app.js index 1a7469b..1a965fc 100644 --- a/internal/adminui/assets/app.js +++ b/internal/adminui/assets/app.js @@ -1,7 +1,8 @@ const state = { token: '', csrf: '', actor: {}, permissions: new Set(), overview: {}, tenants: [], projects: [], keys: [], providers: [], models: [], billingAccounts: [], ledger: [], - usage: [], usageSummary: [], limits: [], users: [], audit: [] + usage: [], usageSummary: [], limits: [], users: [], audit: [], orders: [], sessions: [], + mfa: {totp_enabled:false,passkeys:[]}, pendingMFA: null, authConfig: {} }; const $ = (selector) => document.querySelector(selector); const $$ = (selector) => [...document.querySelectorAll(selector)]; @@ -44,7 +45,49 @@ function integer(value) { return new Intl.NumberFormat().format(Number(value || function emptyRow(span) { return `<tr><td colspan="${span}" class="empty">No records yet</td></tr>`; } function showSecret(title, value) { $('#secret-title').textContent = title; $('#created-secret').textContent = value; $('#secret-dialog').showModal(); } function cookie(name) { const prefix=`${encodeURIComponent(name)}=`; const value=document.cookie.split('; ').find(item=>item.startsWith(prefix)); return value ? decodeURIComponent(value.slice(prefix.length)) : ''; } -function authError(message='') { $('#auth-error').textContent=message; } +function authError(message='', success=false) { const node=$('#auth-error');node.textContent=message;node.classList.toggle('success',Boolean(message)&&success); } +function showAuthPane(id) { + $$('.auth-pane').forEach(node => node.classList.toggle('active', node.id === id)); + $$('.auth-tab').forEach(node => node.classList.toggle('active', node.dataset.authPane === id)); +} + +function base64URLBytes(value) { + const normalized=String(value).replaceAll('-','+').replaceAll('_','/'); + const padded=normalized+'='.repeat((4-normalized.length%4)%4); + return Uint8Array.from(atob(padded),char=>char.charCodeAt(0)); +} +function bytesBase64URL(value) { + if (value == null) return null; + const bytes=new Uint8Array(value); let text=''; bytes.forEach(byte=>{text+=String.fromCharCode(byte);}); + return btoa(text).replaceAll('+','-').replaceAll('/','_').replace(/=+$/,''); +} +function browserPublicKey(options) { + const value=structuredClone(options.publicKey||options); + value.challenge=base64URLBytes(value.challenge); + if(value.user?.id)value.user.id=base64URLBytes(value.user.id); + if(value.excludeCredentials)value.excludeCredentials=value.excludeCredentials.map(item=>({...item,id:base64URLBytes(item.id)})); + if(value.allowCredentials)value.allowCredentials=value.allowCredentials.map(item=>({...item,id:base64URLBytes(item.id)})); + return value; +} +function browserCredential(value) { + const response={clientDataJSON:bytesBase64URL(value.response.clientDataJSON)}; + for(const key of ['attestationObject','authenticatorData','signature','userHandle']){ + if(value.response[key]!=null)response[key]=bytesBase64URL(value.response[key]); + } + if(typeof value.response.getTransports==='function')response.transports=value.response.getTransports(); + return {id:value.id,rawId:bytesBase64URL(value.rawId),type:value.type,response, + authenticatorAttachment:value.authenticatorAttachment,clientExtensionResults:value.getClientExtensionResults()}; +} +async function createPasskey(options) { + const credential=await navigator.credentials.create({publicKey:browserPublicKey(options)}); + if(!credential)throw new Error('Passkey creation was cancelled'); + return browserCredential(credential); +} +async function getPasskey(options) { + const credential=await navigator.credentials.get({publicKey:browserPublicKey(options)}); + if(!credential)throw new Error('Passkey authentication was cancelled'); + return browserCredential(credential); +} async function permitted(permission, path) { if (!can(permission)) return []; return api(path); } async function loadAll(knownSession = null) { @@ -56,9 +99,12 @@ async function loadAll(knownSession = null) { permitted('platform.read','/providers'), permitted('platform.read','/models'), permitted('usage.read','/usage'), permitted('usage.read','/usage/summary'), permitted('limits.read','/limits'), permitted('users.read','/users'), permitted('audit.read','/audit'), state.overview.billing_enabled ? permitted('billing.read','/billing/accounts') : [], - state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : [] + state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : [], + state.actor.id ? api('/auth/mfa') : {totp_enabled:false,passkeys:[]}, + state.actor.id ? api('/auth/sessions') : [], + state.actor.tenant_id && state.overview.billing_enabled && can('billing.read') ? api('/billing/orders') : [] ]); - [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger] = results; + [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger,state.mfa,state.sessions,state.orders] = results; renderAll(); setConnected(true); return true; } catch (error) { setConnected(false); if (error.status !== 401) toast(error.message, true); return false; } } @@ -72,7 +118,7 @@ function applyPermissions() { } function renderAll() { applyPermissions(); renderOverview(); renderTenants(); renderProjects(); renderKeys(); renderProviders(); renderModels(); renderBilling(); - renderUsage(); renderLimits(); renderUsers(); renderAudit(); renderRouteEditor(); + renderUsage(); renderLimits(); renderUsers(); renderAudit(); renderRouteEditor(); renderSecurity(); } function renderOverview() { const data = state.overview; const propagation = !data.redis_configured ? 'PG polling' : data.redis_connected ? 'Redis live' : 'PG fallback'; @@ -97,6 +143,16 @@ function renderBilling() { $('#billing-accounts-body').innerHTML=state.billingAccounts.map(item=>`<tr><td><strong>${esc(item.tenant_name)}</strong><br><code>${shortID(item.tenant_id)}</code></td><td>${money(item.balance_micros,item.currency)}</td><td>${money(item.reserved_micros,item.currency)}</td><td><strong>${money(item.available_micros,item.currency)}</strong></td><td>${date(item.updated_at)}</td></tr>`).join('')||emptyRow(5); $('#billing-ledger-body').innerHTML=state.ledger.map(item=>`<tr><td>${date(item.created_at)}</td><td><code>${shortID(item.tenant_id)}</code></td><td><span class="tag">${esc(item.kind)}</span></td><td class="${item.amount_micros>=0?'money-positive':'money-negative'}">${money(item.amount_micros,item.currency)}</td><td>${money(item.balance_after_micros,item.currency)}</td><td title="${esc(item.description)}"><code>${shortID(item.source_id)}</code></td></tr>`).join('')||emptyRow(6); } +function renderSecurity() { + const totp=Boolean(state.mfa?.totp_enabled); + $$('#account [autocomplete="username"]').forEach(node=>{node.value=state.actor.email||'';}); + $('#totp-action').textContent=totp?'Disable TOTP':'Set up TOTP'; + $('#totp-disable-code').classList.toggle('hidden',!totp); + $('#totp-disable-code input').required=totp; + $('#sessions-body').innerHTML=(state.sessions||[]).map(item=>`<tr><td><strong>${esc(item.current?'Current device':'Browser session')}</strong><br><span class="muted">${esc(item.user_agent||'Unknown client')}</span></td><td><code>${esc(item.remote_ip||'—')}</code></td><td><span class="tag">${esc(item.auth_method)}</span></td><td>${date(item.last_seen_at)}</td><td><button class="text-button danger" data-revoke-session="${esc(item.id)}">Sign out</button></td></tr>`).join('')||emptyRow(5); + $('#passkeys-body').innerHTML=(state.mfa?.passkeys||[]).map(item=>`<tr><td><strong>${esc(item.name)}</strong></td><td>${date(item.created_at)}</td><td>${date(item.last_used_at)}</td><td><button class="text-button danger" data-delete-passkey="${esc(item.id)}">Delete</button></td></tr>`).join('')||emptyRow(4); + $('#orders-body').innerHTML=(state.orders||[]).map(item=>`<tr><td>${date(item.created_at)}</td><td>${money(item.amount_micros,item.currency)}</td><td><span class="badge ${item.status==='paid'?'active':'suspended'}">${esc(item.status)}</span></td><td><code>${shortID(item.id)}</code></td></tr>`).join('')||emptyRow(4); +} function renderUsage() { $('#usage-summary-body').innerHTML=state.usageSummary.map(item=>`<tr><td>${new Date(item.period_start).toLocaleDateString(undefined,{year:'numeric',month:'short'})}</td><td><strong>${esc(item.project_name)}</strong></td><td>${integer(item.request_count)}</td><td>${percent(item.successful_requests,item.request_count)}</td><td>${integer(item.input_tokens)}</td><td>${integer(item.output_tokens)}</td><td>${money(item.cost_micros)}</td></tr>`).join('')||emptyRow(7); $('#usage-events-body').innerHTML=state.usage.map(item=>`<tr><td>${date(item.started_at)}</td><td><code title="${esc(item.request_id)}">${shortID(item.request_id)}</code></td><td>${esc(item.public_model)}</td><td><span class="badge ${item.success?'active':'suspended'}">${item.status_code}</span>${item.error_type?`<small class="error-label">${esc(item.error_type)}</small>`:''}</td><td>${integer(item.total_tokens)}</td><td>${money(item.cost_micros)}</td><td>${integer(item.duration_ms)} ms</td></tr>`).join('')||emptyRow(7); @@ -108,7 +164,7 @@ function renderLimits() { function renderUsers() { const roles = state.actor.tenant_id ? [['tenant_admin','Tenant admin'],['tenant_billing','Billing'],['tenant_developer','Developer'],['tenant_viewer','Viewer']] : [['platform_admin','Platform admin'],['platform_viewer','Platform viewer'],['tenant_admin','Tenant admin'],['tenant_billing','Billing'],['tenant_developer','Developer'],['tenant_viewer','Viewer']]; $('#user-role').innerHTML=roles.map(([value,label])=>`<option value="${value}">${label}</option>`).join(''); - $('#users-body').innerHTML=state.users.map(item=>`<tr><td><strong>${esc(item.display_name)}</strong><br><span class="muted">${esc(item.email)}</span></td><td><span class="tag">${esc(item.role.replaceAll('_',' '))}</span></td><td><code>${shortID(item.tenant_id)}</code></td><td><span class="badge ${item.has_password?'active':'suspended'}">${item.has_password?'password':'legacy token'}</span></td><td>${date(item.last_used_at)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status==='active'&&can('users.write')?`<button class="text-button danger" data-revoke-user="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('')||emptyRow(7); + $('#users-body').innerHTML=state.users.map(item=>`<tr><td><strong>${esc(item.display_name)}</strong><br><span class="muted">${esc(item.email)}</span></td><td><span class="tag">${esc(item.role.replaceAll('_',' '))}</span></td><td><code>${shortID(item.tenant_id)}</code></td><td>${item.email_verified?'<span class="badge active">verified</span>':'<span class="badge suspended">pending</span>'} ${(item.mfa_methods||[]).map(method=>`<span class="tag">${esc(method)}</span>`).join('')}</td><td>${date(item.last_used_at)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status!=='revoked'&&can('users.write')?`<button class="text-button danger" data-revoke-user="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('')||emptyRow(7); } function renderAudit() { $('#audit-body').innerHTML=state.audit.map(item=>`<tr><td>${date(item.created_at)}</td><td><span class="tag">${esc(item.actor_role.replaceAll('_',' '))}</span></td><td>${esc(item.action)}</td><td><code>${esc(item.method)}</code></td><td><span class="badge ${item.status_code<400?'active':'suspended'}">${item.status_code}</span></td><td><code>${shortID(item.request_id)}</code></td><td><code>${esc(item.remote_ip||'—')}</code></td></tr>`).join('')||emptyRow(7); } function renderRouteEditor() { const current=$('#route-editor');if(!current.children.length&&can('platform.write'))addRoute();$$('.route-provider').forEach(select=>{const selected=select.value;select.innerHTML=selectOptions(state.providers.filter(item=>item.enabled),'id','name','Provider…');select.value=selected;}); } @@ -123,12 +179,21 @@ document.addEventListener('click',async(event)=>{ const revokeUser=event.target.closest('[data-revoke-user]');if(revokeUser&&confirm('Revoke this console credential?')){try{await api(`/users/${revokeUser.dataset.revokeUser}/revoke`,{method:'POST',body:'{}'});await loadAll();toast('Console credential revoked');}catch(error){toast(error.message,true);}} const provider=event.target.closest('[data-toggle-provider]');if(provider){try{await api(`/providers/${provider.dataset.toggleProvider}/toggle`,{method:'POST',body:JSON.stringify({enabled:provider.dataset.enabled==='true'})});await loadAll();toast('Provider updated');}catch(error){toast(error.message,true);}} const model=event.target.closest('[data-toggle-model]');if(model){try{await api(`/models/${model.dataset.toggleModel}/toggle`,{method:'POST',body:JSON.stringify({enabled:model.dataset.enabled==='true'})});await loadAll();toast('Model updated');}catch(error){toast(error.message,true);}} + const session=event.target.closest('[data-revoke-session]');if(session&&confirm('Sign out this device?')){try{const result=await api(`/auth/sessions/${session.dataset.revokeSession}/revoke`,{method:'POST',body:'{}'});if(result.current){state.csrf='';state.actor={};setConnected(false);}else{await loadAll();toast('Device signed out');}}catch(error){toast(error.message,true);}} + const passkey=event.target.closest('[data-delete-passkey]');if(passkey&&confirm('Delete this passkey?')){const password=$('#passkey-form [name=current_password]').value;if(!password){toast('Enter your current password in the Passkey panel',true);}else{try{await api(`/auth/passkeys/${passkey.dataset.deletePasskey}/delete`,{method:'POST',body:JSON.stringify({current_password:password})});await loadAll();toast('Passkey deleted');}catch(error){toast(error.message,true);}}} const save=event.target.closest('.save-limit');if(save){const row=save.closest('[data-limit-project]');try{await api(`/limits/${row.dataset.limitProject}`,{method:'POST',body:JSON.stringify({requests_per_minute:Number(row.querySelector('.limit-rpm').value),tokens_per_minute:Number(row.querySelector('.limit-tpm').value),concurrent_requests:Number(row.querySelector('.limit-concurrency').value),monthly_spend_micros:decimalToScaled(row.querySelector('.limit-spend').value,6)})});await loadAll();toast('Project limits updated');}catch(error){toast(error.message,true);}} }); $('#key-tenant').addEventListener('change',renderKeyProjects); -$('#login-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';const result=await api('/auth/login',{method:'POST',body:JSON.stringify(formJSON(event.target))});state.csrf=result.csrf_token||cookie('aigw_csrf');await loadAll();event.target.reset();}catch(error){authError(error.message);}}); -$('#register-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';const result=await api('/auth/register',{method:'POST',body:JSON.stringify(formJSON(event.target))});state.csrf=result.csrf_token||cookie('aigw_csrf');await loadAll();event.target.reset();}catch(error){authError(error.message);}}); +async function completeBrowserLogin(result,form){state.csrf=result.csrf_token||cookie('aigw_csrf');state.pendingMFA=null;if(form)form.reset();await loadAll(result);} +$('#login-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';const result=await api('/auth/login',{method:'POST',body:JSON.stringify(formJSON(event.target))});if(result.mfa_required){state.pendingMFA=result;$('#mfa-passkey').classList.toggle('hidden',!result.methods.includes('passkey'));showAuthPane('mfa-pane');}else await completeBrowserLogin(result,event.target);}catch(error){authError(error.message);}}); +$('#register-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';await api('/auth/register',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();showAuthPane('login-pane');authError('Verification email sent.',true);}catch(error){authError(error.message);}}); +$('#mfa-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();if(!state.pendingMFA)return showAuthPane('login-pane');try{const result=await api('/auth/mfa/totp',{method:'POST',body:JSON.stringify({challenge_token:state.pendingMFA.challenge_token,code:formJSON(event.target).code})});await completeBrowserLogin(result,event.target);}catch(error){authError(error.message);}}); +$('#reset-request-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{await api('/auth/password-reset/request',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();showAuthPane('login-pane');authError('Password reset email sent if the account exists.',true);}catch(error){authError(error.message);}}); +$('#reset-complete-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{await api('/auth/password-reset/complete',{method:'POST',body:JSON.stringify(formJSON(event.target))});history.replaceState({},'',location.pathname);event.target.reset();showAuthPane('login-pane');authError('Password updated. Sign in again.',true);}catch(error){authError(error.message);}}); +$('#invite-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{const result=await api('/auth/invite/accept',{method:'POST',body:JSON.stringify(formJSON(event.target))});history.replaceState({},'',location.pathname);await completeBrowserLogin(result,event.target);}catch(error){authError(error.message);}}); +$('#passkey-login').addEventListener('click',async()=>{authError();if(!state.authConfig.passkeys_enabled)return authError('Passkeys are not configured.');const email=$('#login-pane [name=email]').value;if(!email)return authError('Enter your email first.');try{const begin=await api('/auth/passkey/login/options',{method:'POST',body:JSON.stringify({email})});const credential=await getPasskey(begin.options);const result=await api('/auth/passkey/login',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,credential})});await completeBrowserLogin(result,$('#login-pane'));}catch(error){authError(error.message);}}); +$('#mfa-passkey').addEventListener('click',async()=>{authError();if(!state.pendingMFA)return showAuthPane('login-pane');try{const begin=await api('/auth/mfa/passkey/login/options',{method:'POST',body:JSON.stringify({challenge_token:state.pendingMFA.challenge_token})});const credential=await getPasskey(begin.options);const result=await api('/auth/mfa/passkey/login',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,credential})});await completeBrowserLogin(result,$('#mfa-pane'));}catch(error){authError(error.message);}}); $('#bootstrap-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();state.csrf='';state.token=formJSON(event.target).token.trim();if(!await loadAll()){state.token='';authError('Bootstrap token is invalid');}event.target.reset();}); $('#sign-out').addEventListener('click',async()=>{try{if(!state.token)await api('/auth/logout',{method:'POST',body:'{}'});}catch(error){if(error.status!==401)toast(error.message,true);}state.token='';state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);}); $('#tenant-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/tenants',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Tenant created');}catch(error){toast(error.message,true);}}); @@ -138,8 +203,13 @@ $('#provider-form').addEventListener('submit',async(event)=>{event.preventDefaul $('#model-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.input_price_micros_per_million=decimalToScaled(data.input_price,6);data.output_price_micros_per_million=decimalToScaled(data.output_price,6);data.cache_read_price_micros_per_million=decimalToScaled(data.cache_read_price,6);data.cache_write_price_micros_per_million=decimalToScaled(data.cache_write_price,6);delete data.input_price;delete data.output_price;delete data.cache_read_price;delete data.cache_write_price;data.routes=$$('.route-row').map(row=>({provider_id:row.querySelector('.route-provider').value,upstream_model:row.querySelector('.route-upstream').value,priority:Number(row.querySelector('.route-priority').value),weight:Number(row.querySelector('.route-weight').value)}));await api('/models',{method:'POST',body:JSON.stringify(data)});event.target.reset();$('#route-editor').innerHTML='';renderRouteEditor();await loadAll();toast('Model created');}catch(error){toast(error.message,true);}}); $('#topup-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);const digits=currencyDigits(state.overview.billing_currency||'usd');const result=await api('/billing/checkout-sessions',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_minor:decimalToScaled(data.amount,digits)})});window.location.assign(result.url);}catch(error){toast(error.message,true);}}); $('#adjustment-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/billing/adjustments',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_micros:decimalToScaled(data.amount,6),description:data.description})});event.target.reset();await loadAll();toast('Balance adjusted');}catch(error){toast(error.message,true);}}); -$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Member created');}catch(error){toast(error.message,true);}}); -$('#password-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/auth/password',{method:'POST',body:JSON.stringify({current_password:data.current_password,new_password:data.new_password})});event.target.reset();state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);authError('Password changed. Sign in again.');}catch(error){toast(error.message,true);}}); +$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Invitation sent');}catch(error){toast(error.message,true);}}); +$('#password-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/auth/password',{method:'POST',body:JSON.stringify({current_password:data.current_password,new_password:data.new_password})});event.target.reset();state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);authError('Password changed. Sign in again.',true);}catch(error){toast(error.message,true);}}); +$('#totp-begin-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);if(state.mfa.totp_enabled){await api('/auth/mfa/totp/disable',{method:'POST',body:JSON.stringify(data)});event.target.reset();await loadAll();toast('TOTP disabled');return;}const result=await api('/auth/mfa/totp/begin',{method:'POST',body:JSON.stringify({current_password:data.current_password})});$('#totp-qr').src=result.qr_code;$('#totp-secret').textContent=result.secret;$('#totp-confirm-form').classList.remove('hidden');toast('Authenticator enrollment started');}catch(error){toast(error.message,true);}}); +$('#totp-confirm-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const result=await api('/auth/mfa/totp/confirm',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();$('#totp-confirm-form').classList.add('hidden');showSecret('Recovery codes',result.recovery_codes.join('\n'));await loadAll();}catch(error){toast(error.message,true);}}); +$('#passkey-form').addEventListener('submit',async(event)=>{event.preventDefault();try{if(!state.authConfig.passkeys_enabled)throw new Error('Passkeys are not configured');const data=formJSON(event.target);const begin=await api('/auth/mfa/passkey/options',{method:'POST',body:JSON.stringify({current_password:data.current_password})});const credential=await createPasskey(begin.options);await api('/auth/mfa/passkey',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,name:data.name,credential})});event.target.reset();await loadAll();toast('Passkey added');}catch(error){toast(error.message,true);}}); +$('#revoke-other-sessions').addEventListener('click',async()=>{if(!confirm('Sign out every other device?'))return;try{await api('/auth/sessions/revoke-others',{method:'POST',body:'{}'});await loadAll();toast('Other devices signed out');}catch(error){toast(error.message,true);}}); $('#close-dialog').addEventListener('click',()=>$('#secret-dialog').close());$('#copy-secret').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#created-secret').textContent);toast('Credential copied');}); -async function start(){try{const config=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!config.registration_enabled);if(!config.registration_enabled&&$('#register-tab').classList.contains('active'))$('.auth-tab[data-auth-pane="login-pane"]').click();state.csrf=cookie('aigw_csrf');const session=await api('/auth/session');if(session.authenticated)await loadAll(session);else setConnected(false);}catch(error){setConnected(false);authError(error.message);}} +async function pollTopUp(orderID){for(let attempt=0;attempt<20;attempt++){const order=await api(`/billing/orders/${encodeURIComponent(orderID)}`);if(order.status==='paid'){await loadAll();toast('Balance credited');return;}if(['failed','expired'].includes(order.status)){toast(`Top-up ${order.status}`,true);return;}await new Promise(resolve=>setTimeout(resolve,1500));}toast('Payment is still processing');} +async function start(){try{state.authConfig=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!state.authConfig.registration_enabled);if(!state.authConfig.registration_enabled&&$('#register-tab').classList.contains('active'))showAuthPane('login-pane');const params=new URLSearchParams(location.search);const action=params.get('action');const token=params.get('token');if(action==='reset-password'&&token){$('#reset-token').value=token;showAuthPane('reset-complete-pane');setConnected(false);return;}if(action==='accept-invite'&&token){$('#invite-token').value=token;showAuthPane('invite-pane');setConnected(false);return;}state.csrf=cookie('aigw_csrf');if(action==='verify-email'&&token){const result=await api('/auth/email/verify',{method:'POST',body:JSON.stringify({token})});history.replaceState({},'',location.pathname);await completeBrowserLogin(result);return;}const session=await api('/auth/session');if(session.authenticated){await loadAll(session);const orderID=params.get('order_id');if(params.get('topup')==='success'&&orderID){history.replaceState({},'',location.pathname);pollTopUp(orderID).catch(error=>toast(error.message,true));}else if(params.get('topup')==='cancel'){history.replaceState({},'',location.pathname);toast('Top-up cancelled');}}else setConnected(false);}catch(error){setConnected(false);authError(error.message);}} start(); diff --git a/internal/adminui/assets/index.html b/internal/adminui/assets/index.html index ca9f282..95ab153 100644 --- a/internal/adminui/assets/index.html +++ b/internal/adminui/assets/index.html @@ -14,23 +14,50 @@ <div class="auth-tabs" role="tablist" aria-label="Account access"> <button class="auth-tab active" type="button" data-auth-pane="login-pane">Sign in</button> <button class="auth-tab" type="button" data-auth-pane="register-pane" id="register-tab">Create account</button> + <button class="auth-tab" type="button" data-auth-pane="reset-request-pane">Reset password</button> + <button class="auth-tab" type="button" data-auth-pane="invite-pane">Accept invite</button> <button class="auth-tab" type="button" data-auth-pane="bootstrap-pane">Operator</button> </div> <form class="auth-pane active" id="login-pane"> <div><span class="eyebrow">ACCOUNT ACCESS</span><h1>Sign in</h1></div> <label>Email<input name="email" type="email" required autocomplete="username" placeholder="you@company.com"></label> <label>Password<input name="password" type="password" required autocomplete="current-password"></label> - <button class="button primary" type="submit">Sign in</button> + <div class="form-actions"><button class="button primary" type="submit">Sign in</button><button class="button subtle" type="button" id="passkey-login">Use passkey</button></div> </form> <form class="auth-pane" id="register-pane"> <div><span class="eyebrow">NEW WORKSPACE</span><h1>Create account</h1></div> <label>Organization<input name="organization" required autocomplete="organization" placeholder="Acme Inc."></label> - <label>Workspace slug<input name="tenant_slug" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="acme"></label> + <label>Workspace slug<input name="tenant_slug" required pattern="[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]" placeholder="acme"></label> <label>Your name<input name="display_name" required autocomplete="name"></label> <label>Email<input name="email" type="email" required autocomplete="email"></label> <label>Password<input name="password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label> <button class="button primary" type="submit">Create account</button> </form> + <form class="auth-pane" id="mfa-pane"> + <div><span class="eyebrow">SECOND FACTOR</span><h1>Verify sign in</h1></div> + <label>Code<input name="code" inputmode="numeric" autocomplete="one-time-code" required></label> + <div class="form-actions"><button class="button primary" type="submit">Verify</button><button class="button subtle" type="button" id="mfa-passkey">Use passkey</button></div> + </form> + <form class="auth-pane" id="reset-request-pane"> + <div><span class="eyebrow">ACCOUNT RECOVERY</span><h1>Reset password</h1></div> + <label>Email<input name="email" type="email" required autocomplete="email"></label> + <button class="button primary" type="submit">Send reset link</button> + </form> + <form class="auth-pane" id="reset-complete-pane"> + <div><span class="eyebrow">ACCOUNT RECOVERY</span><h1>Choose a new password</h1></div> + <input name="token" id="reset-token" type="hidden"> + <input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1"> + <label>New password<input name="new_password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label> + <button class="button primary" type="submit">Update password</button> + </form> + <form class="auth-pane" id="invite-pane"> + <div><span class="eyebrow">TEAM ACCESS</span><h1>Accept invitation</h1></div> + <input name="token" id="invite-token" type="hidden"> + <input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1"> + <label>Name<input name="display_name" required autocomplete="name"></label> + <label>Password<input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label> + <button class="button primary" type="submit">Join workspace</button> + </form> <form class="auth-pane" id="bootstrap-pane"> <div><span class="eyebrow">BREAK GLASS</span><h1>Operator access</h1></div> <input class="visually-hidden" name="username" autocomplete="username" value="aigw-operator" aria-hidden="true" tabindex="-1"> @@ -78,7 +105,7 @@ <section id="tenants" class="section"> <div class="section-heading"><div><span class="eyebrow">IDENTITY</span><h1>Tenants</h1></div></div> - <form class="panel form-grid" id="tenant-form" data-permission="tenants.write"><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="acme"></label><label>Name<input name="name" required placeholder="Acme Inc."></label><button class="button primary" type="submit">Create tenant</button></form> + <form class="panel form-grid" id="tenant-form" data-permission="tenants.write"><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]" placeholder="acme"></label><label>Name<input name="name" required placeholder="Acme Inc."></label><button class="button primary" type="submit">Create tenant</button></form> <div class="panel table-wrap"><table><thead><tr><th>Name</th><th>Slug</th><th>Status</th><th>Created</th></tr></thead><tbody id="tenants-body"></tbody></table></div> </section> @@ -125,13 +152,21 @@ <section id="team" class="section"> <div class="section-heading"><div><span class="eyebrow">RBAC</span><h1>Console access</h1></div></div> - <form class="panel form-grid" id="user-form" data-permission="users.write"><label>Tenant<select name="tenant_id" id="user-tenant"></select></label><label>Email<input name="email" type="email" required autocomplete="email" placeholder="operator@example.com"></label><label>Display name<input name="display_name" required placeholder="Operations"></label><label>Role<select name="role" id="user-role" required></select></label><label>Temporary password<input name="password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label><button class="button primary" type="submit">Create member</button></form> + <form class="panel form-grid" id="user-form" data-permission="users.write"><label>Tenant<select name="tenant_id" id="user-tenant"></select></label><label>Email<input name="email" type="email" required autocomplete="email" placeholder="operator@example.com"></label><label>Display name<input name="display_name" required placeholder="Operations"></label><label>Role<select name="role" id="user-role" required></select></label><button class="button primary" type="submit">Invite member</button></form> <div class="panel table-wrap"><table><thead><tr><th>User</th><th>Role</th><th>Tenant</th><th>Login</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody id="users-body"></tbody></table></div> </section> <section id="account" class="section"> <div class="section-heading"><div><span class="eyebrow">SECURITY</span><h1>Account</h1></div></div> <form class="panel form-grid compact-form" id="password-form"><input class="visually-hidden" name="username" autocomplete="username" aria-hidden="true" tabindex="-1"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label>New password<input name="new_password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label><button class="button primary" type="submit">Change password</button></form> + <div class="account-grid"> + <form class="panel form-grid compact-form" id="totp-begin-form"><h2>Authenticator app</h2><input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label class="hidden" id="totp-disable-code">Authenticator code<input name="code" inputmode="numeric" autocomplete="one-time-code"></label><button class="button secondary" id="totp-action" type="submit">Set up TOTP</button></form> + <form class="panel form-grid compact-form hidden" id="totp-confirm-form"><h2>Confirm authenticator</h2><img id="totp-qr" alt="TOTP QR code"><code id="totp-secret"></code><label>Code<input name="code" inputmode="numeric" autocomplete="one-time-code" required></label><button class="button primary" type="submit">Enable TOTP</button></form> + <form class="panel form-grid compact-form" id="passkey-form"><h2>Passkey</h2><input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label>Passkey name<input name="name" value="This device" required></label><button class="button secondary" type="submit">Add passkey</button></form> + </div> + <div class="panel table-wrap"><div class="section-heading"><h2>Devices</h2><button class="button subtle" id="revoke-other-sessions" type="button">Sign out other devices</button></div><table><thead><tr><th>Device</th><th>IP</th><th>Method</th><th>Last seen</th><th></th></tr></thead><tbody id="sessions-body"></tbody></table></div> + <div class="panel table-wrap"><div class="section-heading"><h2>Passkeys</h2></div><table><thead><tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody id="passkeys-body"></tbody></table></div> + <div class="panel table-wrap"><div class="section-heading"><h2>Top-up orders</h2></div><table><thead><tr><th>Created</th><th>Amount</th><th>Status</th><th>Reference</th></tr></thead><tbody id="orders-body"></tbody></table></div> </section> <section id="audit" class="section"> diff --git a/internal/adminui/assets/style.css b/internal/adminui/assets/style.css index 745378f..e712da3 100644 --- a/internal/adminui/assets/style.css +++ b/internal/adminui/assets/style.css @@ -1,6 +1,6 @@ :root { --bg:#f3f5f7; --panel:#fff; --ink:#18212b; --muted:#71808e; --line:#dce3e8; --accent:#146c94; --accent-soft:#e5f2f7; --danger:#b4494d; --shadow:0 8px 24px rgba(29,47,61,.06); font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; } * { box-sizing:border-box; } body { margin:0; color:var(--ink); background:var(--bg); font-size:14px; } button,input,select { font:inherit; } button { cursor:pointer; } -.auth-screen { min-height:100vh; display:grid; grid-template-columns:minmax(260px,1fr) minmax(360px,520px); background:#102a3a; } .auth-brand { color:#fff; display:flex; align-items:flex-start; gap:12px; padding:38px; } .auth-brand strong { display:block; font-size:18px; } .auth-brand small { display:block; color:#8ba9b9; font-size:10px; margin-top:3px; } .auth-panel { background:#fff; padding:clamp(30px,7vh,72px) clamp(28px,6vw,72px); overflow:auto; } .auth-tabs { display:flex; border-bottom:1px solid var(--line); margin-bottom:34px; } .auth-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--muted); padding:11px 12px; white-space:nowrap; } .auth-tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; } .auth-pane { display:none; gap:18px; } .auth-pane.active { display:grid; } .auth-pane h1 { margin-bottom:8px; } .auth-pane .button { margin-top:4px; } .auth-error { color:var(--danger); min-height:20px; margin:18px 0 0; font-size:12px; } +.auth-screen { min-height:100vh; display:grid; grid-template-columns:minmax(260px,1fr) minmax(360px,560px); background:#102a3a; } .auth-brand { color:#fff; display:flex; align-items:flex-start; gap:12px; padding:38px; } .auth-brand strong { display:block; font-size:18px; } .auth-brand small { display:block; color:#8ba9b9; font-size:10px; margin-top:3px; } .auth-panel { background:#fff; padding:clamp(30px,7vh,72px) clamp(28px,5vw,64px); overflow:auto; } .auth-tabs { display:flex; overflow:auto; border-bottom:1px solid var(--line); margin-bottom:34px; } .auth-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--muted); padding:11px 12px; white-space:nowrap; } .auth-tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; } .auth-pane { display:none; gap:18px; } .auth-pane.active { display:grid; } .auth-pane h1 { margin-bottom:8px; } .auth-pane .button { margin-top:4px; } .auth-error { color:var(--danger); min-height:20px; margin:18px 0 0; font-size:12px; } .auth-error.success { color:#187151; } .form-actions { display:flex; gap:8px; } .form-actions .button { flex:1; } .topbar { height:72px; background:#102a3a; color:#fff; padding:0 32px; display:flex; align-items:center; justify-content:space-between; gap:24px; } .brand { display:flex; align-items:center; gap:11px; letter-spacing:0; } .brand-mark { width:32px; height:32px; display:grid; place-items:center; border:1px solid #8fd0df; color:#b8eef7; font-weight:800; } .brand strong { display:block; font-size:15px; } .brand small { color:#8ba9b9; font-size:9px; letter-spacing:0; } .session { display:flex; align-items:center; gap:12px; } .session div { display:flex; flex-direction:column; align-items:flex-end; gap:2px; } .session button { min-height:36px; border:1px solid #8fd0df; background:transparent; color:#b8eef7; padding:0 13px; font-weight:700; } .session button:hover { background:#18384b; } .state { color:#86d5ad; font-size:11px; text-transform:capitalize; } .actor-label { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#fff; font-size:12px; } .shell { width:min(1240px,calc(100% - 48px)); margin:28px auto 60px; } .tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--line); margin-bottom:26px; } .tab { white-space:nowrap; border:0; background:transparent; color:var(--muted); padding:12px 15px; border-bottom:2px solid transparent; } .tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; } .section { display:none; } .section.active { display:block; } .section-heading { display:flex; justify-content:space-between; align-items:flex-end; gap:20px; margin-bottom:19px; } .eyebrow { color:var(--accent); font-size:10px; letter-spacing:0; font-weight:800; } h1 { font-size:28px; line-height:1.1; margin:7px 0 0; letter-spacing:0; } h2 { margin:4px 0 0; font-size:20px; } @@ -10,8 +10,9 @@ .table-wrap { overflow:auto; padding:0; } table { width:100%; border-collapse:collapse; min-width:700px; } th,td { padding:14px 18px; text-align:left; border-bottom:1px solid var(--line); vertical-align:middle; } th { color:var(--muted); font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0; background:#fbfcfd; } tbody tr:last-child td { border-bottom:0; } td { font-size:13px; } code { font-family:"SFMono-Regular",Consolas,monospace; font-size:12px; color:#486071; } .badge,.tag { display:inline-flex; align-items:center; padding:4px 7px; font-size:11px; line-height:1; } .badge { border:1px solid #d9e0e4; color:var(--muted); } .badge.active { color:#187151; background:#e9f7f0; border-color:#c7e9d9; } .badge.revoked,.badge.suspended { color:var(--danger); background:#fff0f0; border-color:#f0cccc; } .tag { color:#4c6572; background:#eef3f5; margin:2px 3px 2px 0; } .text-button { border:0; background:transparent; color:var(--accent); padding:5px 0; } .text-button.danger { color:var(--danger); } .empty { color:var(--muted); text-align:center; padding:32px; } .truncate { max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .route-editor { grid-column:1/-1; display:flex; flex-direction:column; gap:8px; } .route-row { display:grid; grid-template-columns:1.4fr 1.4fr .6fr .6fr 34px; gap:8px; } .icon-button { border:1px solid var(--line); background:#fff; color:var(--muted); width:34px; height:34px; font-size:19px; } .route-list { display:flex; flex-direction:column; gap:3px; color:#486071; font-size:12px; } .route-list em { color:var(--muted); font-style:normal; margin-left:4px; } .hidden { display:none !important; } .visually-hidden { position:absolute !important; width:1px !important; height:1px !important; padding:0 !important; margin:-1px !important; overflow:hidden !important; clip:rect(0,0,0,0) !important; white-space:nowrap !important; border:0 !important; } .billing-actions { display:grid; grid-template-columns:1fr 1fr; gap:16px; } .compact-form { grid-template-columns:1fr 1fr; } .compact-form .button { grid-column:1/-1; } .currency-label { color:var(--muted); font-size:12px; text-transform:uppercase; } .ledger-heading { margin-top:28px; } .money-positive { color:#187151; } .money-negative { color:var(--danger); } +.account-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; } .account-grid .panel { align-content:start; } #totp-qr { width:180px; height:180px; border:1px solid var(--line); } #totp-secret { overflow-wrap:anywhere; } .table-wrap > .section-heading { padding:18px; margin:0; align-items:center; border-bottom:1px solid var(--line); } .price-line { display:block; color:var(--muted); font-size:10px; margin-top:5px; white-space:nowrap; } .muted,.error-label { display:block; color:var(--muted); font-size:11px; margin-top:4px; } .error-label { color:var(--danger); } .limits-table input { min-width:118px; padding:8px 9px; } .limits-table .button { min-height:36px; } input:disabled,select:disabled { background:#f5f7f8; color:#697985; cursor:not-allowed; } -.toast { position:fixed; bottom:24px; right:24px; background:#102a3a; color:#fff; padding:12px 16px; opacity:0; transform:translateY(8px); pointer-events:none; transition:.2s; } .toast.visible { opacity:1; transform:none; } .toast.error { background:#8f3d42; } dialog { border:0; padding:0; width:min(460px,calc(100% - 32px)); box-shadow:0 18px 70px rgba(0,0,0,.22); } dialog::backdrop { background:rgba(16,42,58,.45); } .dialog-content { padding:24px; } .dialog-content p { color:var(--muted); } .dialog-content code { display:block; background:#f3f5f7; padding:15px; overflow:auto; color:var(--ink); margin:18px 0; } -@media (max-width:900px) { .metric-grid { grid-template-columns:repeat(3,1fr); } .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .form-grid .button.primary { grid-column:1/-1; } .billing-actions { grid-template-columns:1fr; } } +.toast { position:fixed; bottom:24px; right:24px; background:#102a3a; color:#fff; padding:12px 16px; opacity:0; transform:translateY(8px); pointer-events:none; transition:.2s; } .toast.visible { opacity:1; transform:none; } .toast.error { background:#8f3d42; } dialog { border:0; padding:0; width:min(460px,calc(100% - 32px)); box-shadow:0 18px 70px rgba(0,0,0,.22); } dialog::backdrop { background:rgba(16,42,58,.45); } .dialog-content { padding:24px; } .dialog-content p { color:var(--muted); } .dialog-content code { display:block; background:#f3f5f7; padding:15px; overflow:auto; color:var(--ink); margin:18px 0; white-space:pre-wrap; } +@media (max-width:900px) { .metric-grid { grid-template-columns:repeat(3,1fr); } .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .form-grid .button.primary { grid-column:1/-1; } .billing-actions,.account-grid { grid-template-columns:1fr; } } @media (max-width:620px) { .auth-screen { grid-template-columns:1fr; background:#fff; } .auth-brand { background:#102a3a; padding:22px; } .auth-panel { padding:28px 22px 50px; } .auth-tabs { overflow:auto; } .topbar { height:auto; padding:16px; align-items:flex-start; } .session { margin-left:auto; } .session div { align-items:flex-end; max-width:150px; } .session .actor-label { max-width:150px; } .shell { width:calc(100% - 24px); margin-top:18px; } .tabs { margin-bottom:20px; flex-wrap:nowrap; overflow:auto; } .metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .metric { padding:14px; } .metric strong { font-size:22px; overflow-wrap:anywhere; } .form-grid { grid-template-columns:1fr; } .route-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr) 34px; } .route-provider,.route-upstream { grid-column:1/-1; } h1 { font-size:24px; } .section-heading { align-items:flex-start; } } diff --git a/internal/billing/ledger.go b/internal/billing/ledger.go index 28cbe4c..c408cfe 100644 --- a/internal/billing/ledger.go +++ b/internal/billing/ledger.go @@ -73,6 +73,44 @@ func (s *Service) ListLedger(ctx context.Context, tenantID string, limit int) ([ return result, rows.Err() } +func (s *Service) ListTopUpOrders(ctx context.Context, tenantID string, limit int) ([]TopUpOrder, error) { + if limit < 1 || limit > 200 { + limit = 50 + } + rows, err := s.db.Query(ctx, `SELECT id::text,tenant_id::text,amount_minor,amount_micros,currency,status, + COALESCE(stripe_session_id,''),COALESCE(checkout_url,''),created_at,paid_at FROM topup_orders + WHERE tenant_id=$1 ORDER BY created_at DESC LIMIT $2`, tenantID, limit) + if err != nil { + return nil, fmt.Errorf("query top-up orders: %w", err) + } + defer rows.Close() + result := make([]TopUpOrder, 0) + for rows.Next() { + var item TopUpOrder + if err := rows.Scan(&item.ID, &item.TenantID, &item.AmountMinor, &item.AmountMicros, &item.Currency, &item.Status, + &item.StripeSessionID, &item.CheckoutURL, &item.CreatedAt, &item.PaidAt); err != nil { + return nil, err + } + result = append(result, item) + } + return result, rows.Err() +} + +func (s *Service) GetTopUpOrder(ctx context.Context, tenantID, orderID string) (TopUpOrder, error) { + var result TopUpOrder + err := s.db.QueryRow(ctx, `SELECT id::text,tenant_id::text,amount_minor,amount_micros,currency,status, + COALESCE(stripe_session_id,''),COALESCE(checkout_url,''),created_at,paid_at FROM topup_orders + WHERE id=$1 AND tenant_id=$2`, orderID, tenantID).Scan(&result.ID, &result.TenantID, &result.AmountMinor, &result.AmountMicros, + &result.Currency, &result.Status, &result.StripeSessionID, &result.CheckoutURL, &result.CreatedAt, &result.PaidAt) + if errors.Is(err, pgx.ErrNoRows) { + return TopUpOrder{}, ErrTopUpOrderNotFound + } + if err != nil { + return TopUpOrder{}, fmt.Errorf("get top-up order: %w", err) + } + return result, nil +} + func (s *Service) AdjustBalance(ctx context.Context, input AdjustmentInput) (LedgerEntry, error) { input.TenantID = strings.TrimSpace(input.TenantID) input.Description = normalizeDescription(input.Description) diff --git a/internal/billing/service_test.go b/internal/billing/service_test.go index 17f8143..f675da7 100644 --- a/internal/billing/service_test.go +++ b/internal/billing/service_test.go @@ -85,6 +85,18 @@ func TestIntegrationIdentifierSuffixUsesLetters(t *testing.T) { } } +func TestCheckoutReturnURLPreservesCallbackAndSessionPlaceholder(t *testing.T) { + success := checkoutReturnURL("https://console.example.test/admin/?topup=success", "order-123", true) + if !strings.Contains(success, "topup=success") || !strings.Contains(success, "order_id=order-123") || + !strings.Contains(success, "session_id={CHECKOUT_SESSION_ID}") { + t.Fatalf("unexpected success URL %q", success) + } + cancel := checkoutReturnURL("https://console.example.test/admin/?topup=cancel&session_id=stale", "order-123", false) + if !strings.Contains(cancel, "topup=cancel") || !strings.Contains(cancel, "order_id=order-123") || strings.Contains(cancel, "session_id=") { + t.Fatalf("unexpected cancel URL %q", cancel) + } +} + func TestWebhookRejectsInvalidSignatureBeforeProcessing(t *testing.T) { service := &Service{stripeWebhookSecret: "whsec_test"} request := httptest.NewRequest(http.MethodPost, "/billing/stripe/webhook", strings.NewReader(`{"id":"evt_fake"}`)) diff --git a/internal/billing/stripe.go b/internal/billing/stripe.go index b06eb6a..b887035 100644 --- a/internal/billing/stripe.go +++ b/internal/billing/stripe.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "github.com/jackc/pgx/v5" @@ -32,8 +33,8 @@ func (s *Service) CreateCheckout(ctx context.Context, input CheckoutInput) (Chec Mode: stripe.String("payment"), ClientReferenceID: stripe.String(orderID), IntegrationIdentifier: stripe.String(s.integrationIdentifier), - SuccessURL: stripe.String(s.stripeSuccessURL), - CancelURL: stripe.String(s.stripeCancelURL), + SuccessURL: stripe.String(checkoutReturnURL(s.stripeSuccessURL, orderID, true)), + CancelURL: stripe.String(checkoutReturnURL(s.stripeCancelURL, orderID, false)), Metadata: map[string]string{ "aigw_topup_order_id": orderID, "aigw_tenant_id": strings.TrimSpace(input.TenantID), @@ -67,6 +68,24 @@ func (s *Service) CreateCheckout(ctx context.Context, input CheckoutInput) (Chec return CheckoutResult{OrderID: orderID, SessionID: session.ID, URL: session.URL}, nil } +func checkoutReturnURL(raw, orderID string, includeStripeSession bool) string { + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + query := parsed.Query() + query.Set("order_id", orderID) + if includeStripeSession { + query.Set("session_id", "{CHECKOUT_SESSION_ID}") + } else { + query.Del("session_id") + } + encoded := query.Encode() + encoded = strings.ReplaceAll(encoded, url.QueryEscape("{CHECKOUT_SESSION_ID}"), "{CHECKOUT_SESSION_ID}") + parsed.RawQuery = encoded + return parsed.String() +} + func (s *Service) WebhookHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/internal/billing/types.go b/internal/billing/types.go index 24694cc..3d0461e 100644 --- a/internal/billing/types.go +++ b/internal/billing/types.go @@ -13,6 +13,7 @@ var ( ErrStripeDisabled = errors.New("Stripe top-ups are disabled") ErrInvalidAmount = errors.New("invalid amount") ErrQuotaExceeded = errors.New("monthly spend quota exceeded") + ErrTopUpOrderNotFound = errors.New("top-up order not found") ) type Meter interface { @@ -81,3 +82,16 @@ type CheckoutResult struct { SessionID string `json:"session_id"` URL string `json:"url"` } + +type TopUpOrder struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + AmountMinor int64 `json:"amount_minor"` + AmountMicros int64 `json:"amount_micros"` + Currency string `json:"currency"` + Status string `json:"status"` + StripeSessionID string `json:"stripe_session_id,omitempty"` + CheckoutURL string `json:"checkout_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + PaidAt *time.Time `json:"paid_at,omitempty"` +} diff --git a/internal/config/config.go b/internal/config/config.go index 97ecb21..376cf77 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,7 +26,8 @@ type Config struct { } type ServerConfig struct { - Address string `json:"address"` + Address string `json:"-"` + AddressEnv string `json:"address_env"` MaxBodyBytes int64 `json:"max_body_bytes"` ReadHeaderTimeoutSecs int `json:"read_header_timeout_seconds"` IdleTimeoutSecs int `json:"idle_timeout_seconds"` @@ -53,12 +54,40 @@ type ControlPlaneConfig struct { } type AdminConfig struct { - Enabled bool `json:"enabled"` - TokenEnv string `json:"token_env"` - BasePath string `json:"base_path"` - RegistrationEnabled bool `json:"registration_enabled"` - SessionTTLHours int `json:"session_ttl_hours"` - Token string `json:"-"` + Enabled bool `json:"enabled"` + TokenEnv string `json:"token_env"` + BasePath string `json:"base_path"` + RegistrationEnabled bool `json:"registration_enabled"` + SessionTTLHours int `json:"session_ttl_hours"` + PublicURL string `json:"-"` + PublicURLEnv string `json:"public_url_env"` + Mail MailConfig `json:"mail"` + WebAuthn WebAuthnConfig `json:"webauthn"` + Token string `json:"-"` +} + +type MailConfig struct { + Enabled bool `json:"enabled"` + FromName string `json:"from_name"` + TLSMode string `json:"tls_mode"` + FromAddressEnv string `json:"from_address_env"` + SMTPAddressEnv string `json:"smtp_address_env"` + SMTPUsernameEnv string `json:"smtp_username_env"` + SMTPPasswordEnv string `json:"smtp_password_env"` + SMTPImplicitTLS bool `json:"smtp_implicit_tls"` + FromAddress string `json:"-"` + SMTPAddress string `json:"-"` + SMTPUsername string `json:"-"` + SMTPPassword string `json:"-"` +} + +type WebAuthnConfig struct { + Enabled bool `json:"enabled"` + RPDisplayName string `json:"rp_display_name"` + RPIDEnv string `json:"rp_id_env"` + OriginsEnv string `json:"origins_env"` + RPID string `json:"-"` + Origins []string `json:"-"` } type UpstreamHTTPConfig struct { @@ -69,11 +98,12 @@ type UpstreamHTTPConfig struct { } type ProviderConfig struct { - ID string `json:"id"` - Protocol domain.Protocol `json:"protocol"` - BaseURL string `json:"base_url"` - APIKeyEnv string `json:"api_key_env"` - APIKey string `json:"-"` + ID string `json:"id"` + Protocol domain.Protocol `json:"protocol"` + BaseURL string `json:"-"` + BaseURLEnv string `json:"base_url_env"` + APIKeyEnv string `json:"api_key_env"` + APIKey string `json:"-"` } type ModelConfig struct { @@ -111,8 +141,10 @@ type StripeConfig struct { Enabled bool `json:"enabled"` APIKeyEnv string `json:"api_key_env"` WebhookSecretEnv string `json:"webhook_secret_env"` - SuccessURL string `json:"success_url"` - CancelURL string `json:"cancel_url"` + SuccessURL string `json:"-"` + CancelURL string `json:"-"` + SuccessURLEnv string `json:"success_url_env"` + CancelURLEnv string `json:"cancel_url_env"` APIKey string `json:"-"` WebhookSecret string `json:"-"` } @@ -148,6 +180,9 @@ func Load(path string) (Config, error) { } func applyDefaults(cfg *Config) { + if cfg.Server.AddressEnv == "" { + cfg.Server.AddressEnv = "AIGW_SERVER_ADDRESS" + } if cfg.Server.Address == "" { cfg.Server.Address = ":8080" } @@ -193,6 +228,36 @@ func applyDefaults(cfg *Config) { if cfg.Admin.SessionTTLHours == 0 { cfg.Admin.SessionTTLHours = 12 } + if cfg.Admin.PublicURLEnv == "" { + cfg.Admin.PublicURLEnv = "AIGW_PUBLIC_URL" + } + if cfg.Admin.Mail.FromName == "" { + cfg.Admin.Mail.FromName = "AIGW" + } + if cfg.Admin.Mail.TLSMode == "" { + cfg.Admin.Mail.TLSMode = "starttls" + } + if cfg.Admin.Mail.FromAddressEnv == "" { + cfg.Admin.Mail.FromAddressEnv = "AIGW_SMTP_FROM_ADDRESS" + } + if cfg.Admin.Mail.SMTPAddressEnv == "" { + cfg.Admin.Mail.SMTPAddressEnv = "AIGW_SMTP_ADDRESS" + } + if cfg.Admin.Mail.SMTPUsernameEnv == "" { + cfg.Admin.Mail.SMTPUsernameEnv = "AIGW_SMTP_USERNAME" + } + if cfg.Admin.Mail.SMTPPasswordEnv == "" { + cfg.Admin.Mail.SMTPPasswordEnv = "AIGW_SMTP_PASSWORD" + } + if cfg.Admin.WebAuthn.RPDisplayName == "" { + cfg.Admin.WebAuthn.RPDisplayName = "AIGW Console" + } + if cfg.Admin.WebAuthn.RPIDEnv == "" { + cfg.Admin.WebAuthn.RPIDEnv = "AIGW_WEBAUTHN_RP_ID" + } + if cfg.Admin.WebAuthn.OriginsEnv == "" { + cfg.Admin.WebAuthn.OriginsEnv = "AIGW_WEBAUTHN_ORIGINS" + } if cfg.UpstreamHTTP.MaxIdleConnections == 0 { cfg.UpstreamHTTP.MaxIdleConnections = 4096 } @@ -226,6 +291,12 @@ func applyDefaults(cfg *Config) { if cfg.Billing.Stripe.WebhookSecretEnv == "" { cfg.Billing.Stripe.WebhookSecretEnv = "AIGW_STRIPE_WEBHOOK_SECRET" } + if cfg.Billing.Stripe.SuccessURLEnv == "" { + cfg.Billing.Stripe.SuccessURLEnv = "AIGW_STRIPE_SUCCESS_URL" + } + if cfg.Billing.Stripe.CancelURLEnv == "" { + cfg.Billing.Stripe.CancelURLEnv = "AIGW_STRIPE_CANCEL_URL" + } for i := range cfg.Models { for j := range cfg.Models[i].Routes { if cfg.Models[i].Routes[j].Weight == 0 { @@ -236,6 +307,9 @@ func applyDefaults(cfg *Config) { } func resolveSecrets(cfg *Config) error { + if value := strings.TrimSpace(os.Getenv(cfg.Server.AddressEnv)); value != "" { + cfg.Server.Address = value + } if cfg.ControlPlane.Enabled { cfg.ControlPlane.DatabaseURL = os.Getenv(cfg.ControlPlane.DatabaseURLEnv) cfg.ControlPlane.RedisURL = os.Getenv(cfg.ControlPlane.RedisURLEnv) @@ -243,13 +317,42 @@ func resolveSecrets(cfg *Config) error { } if cfg.Admin.Enabled { cfg.Admin.Token = os.Getenv(cfg.Admin.TokenEnv) + if err := resolveRequiredEnv(&cfg.Admin.PublicURL, cfg.Admin.PublicURLEnv, "admin.public_url"); err != nil { + return err + } + if cfg.Admin.Mail.Enabled { + cfg.Admin.Mail.FromAddress = strings.TrimSpace(os.Getenv(cfg.Admin.Mail.FromAddressEnv)) + cfg.Admin.Mail.SMTPAddress = strings.TrimSpace(os.Getenv(cfg.Admin.Mail.SMTPAddressEnv)) + cfg.Admin.Mail.SMTPUsername = os.Getenv(cfg.Admin.Mail.SMTPUsernameEnv) + cfg.Admin.Mail.SMTPPassword = os.Getenv(cfg.Admin.Mail.SMTPPasswordEnv) + } + if cfg.Admin.WebAuthn.Enabled { + cfg.Admin.WebAuthn.RPID = strings.TrimSpace(os.Getenv(cfg.Admin.WebAuthn.RPIDEnv)) + for _, origin := range strings.Split(os.Getenv(cfg.Admin.WebAuthn.OriginsEnv), ",") { + if origin = strings.TrimSpace(origin); origin != "" { + cfg.Admin.WebAuthn.Origins = append(cfg.Admin.WebAuthn.Origins, origin) + } + } + } } if cfg.Billing.Enabled && cfg.Billing.Stripe.Enabled { cfg.Billing.Stripe.APIKey = os.Getenv(cfg.Billing.Stripe.APIKeyEnv) cfg.Billing.Stripe.WebhookSecret = os.Getenv(cfg.Billing.Stripe.WebhookSecretEnv) + if err := resolveRequiredEnv(&cfg.Billing.Stripe.SuccessURL, cfg.Billing.Stripe.SuccessURLEnv, "billing.stripe.success_url"); err != nil { + return err + } + if err := resolveRequiredEnv(&cfg.Billing.Stripe.CancelURL, cfg.Billing.Stripe.CancelURLEnv, "billing.stripe.cancel_url"); err != nil { + return err + } } for i := range cfg.Providers { provider := &cfg.Providers[i] + if provider.BaseURLEnv == "" { + return fmt.Errorf("provider %q: base_url_env is required", provider.ID) + } + if err := resolveRequiredEnv(&provider.BaseURL, provider.BaseURLEnv, fmt.Sprintf("provider %q base_url", provider.ID)); err != nil { + return err + } if provider.APIKeyEnv == "" { continue } @@ -261,6 +364,18 @@ func resolveSecrets(cfg *Config) error { return nil } +func resolveRequiredEnv(target *string, environment, field string) error { + if environment == "" { + return nil + } + value := strings.TrimSpace(os.Getenv(environment)) + if value == "" { + return fmt.Errorf("%s: environment variable %s is empty", field, environment) + } + *target = value + return nil +} + func Validate(cfg Config) error { if cfg.Server.MaxBodyBytes < 1024 { return errors.New("server.max_body_bytes must be at least 1024") @@ -293,6 +408,34 @@ func Validate(cfg Config) error { if cfg.Admin.SessionTTLHours < 1 || cfg.Admin.SessionTTLHours > 720 { return errors.New("admin.session_ttl_hours must be between 1 and 720") } + publicURL, err := url.Parse(cfg.Admin.PublicURL) + if err != nil || publicURL.Host == "" || (publicURL.Scheme != "http" && publicURL.Scheme != "https") { + return errors.New("admin.public_url must resolve from an environment variable to an absolute http(s) URL") + } + if cfg.Admin.Mail.Enabled { + if cfg.Admin.Mail.FromAddress == "" || cfg.Admin.Mail.SMTPAddress == "" { + return errors.New("admin.mail requires SMTP address and from address environment variables") + } + if (cfg.Admin.Mail.SMTPUsername == "") != (cfg.Admin.Mail.SMTPPassword == "") { + return errors.New("admin.mail SMTP username and password must both be set or both be empty") + } + switch cfg.Admin.Mail.TLSMode { + case "starttls", "tls", "none": + default: + return errors.New("admin.mail.tls_mode must be starttls, tls, or none") + } + } + if cfg.Admin.WebAuthn.Enabled { + if cfg.Admin.WebAuthn.RPID == "" || len(cfg.Admin.WebAuthn.Origins) == 0 { + return errors.New("admin.webauthn requires RP ID and origins environment variables") + } + for _, origin := range cfg.Admin.WebAuthn.Origins { + parsed, err := url.Parse(origin) + if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && !(parsed.Scheme == "http" && parsed.Hostname() == "localhost")) { + return fmt.Errorf("admin.webauthn origin %q must use https (http is allowed only for localhost)", origin) + } + } + } } if cfg.Billing.Enabled { if !cfg.ControlPlane.Enabled { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2331b0f..1ecf70f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -8,20 +8,25 @@ import ( func TestLoadAppliesDefaultsAndResolvesSecrets(t *testing.T) { t.Setenv("TEST_UPSTREAM_KEY", "secret") + t.Setenv("TEST_UPSTREAM_URL", "https://example.com/v1") + t.Setenv("AIGW_SERVER_ADDRESS", "127.0.0.1:9090") path := writeConfig(t, `{ - "providers": [{"id":"primary","protocol":"openai","base_url":"https://example.com/v1","api_key_env":"TEST_UPSTREAM_KEY"}], + "providers": [{"id":"primary","protocol":"openai","base_url_env":"TEST_UPSTREAM_URL","api_key_env":"TEST_UPSTREAM_KEY"}], "models": [{"id":"example/model","routes":[{"provider":"primary","upstream_model":"model"}]}] }`) cfg, err := Load(path) if err != nil { t.Fatal(err) } - if cfg.Server.Address != ":8080" || cfg.Server.MaxBodyBytes == 0 { + if cfg.Server.Address != "127.0.0.1:9090" || cfg.Server.MaxBodyBytes == 0 { t.Fatalf("defaults not applied: %+v", cfg.Server) } if cfg.Providers[0].APIKey != "secret" { t.Fatal("provider secret was not resolved") } + if cfg.Providers[0].BaseURL != "https://example.com/v1" { + t.Fatal("provider URL was not resolved") + } if cfg.Models[0].Routes[0].Weight != 1 { t.Fatalf("expected default route weight 1, got %d", cfg.Models[0].Routes[0].Weight) } @@ -40,11 +45,24 @@ func TestLoadRejectsUnknownFieldsAndTrailingData(t *testing.T) { } } +func TestLoadRejectsLiteralExternalServiceValues(t *testing.T) { + for _, content := range []string{ + `{"server":{"address":":9090"}}`, + `{"providers":[{"id":"primary","protocol":"openai","base_url":"https://example.test","api_key_env":"TEST_KEY"}]}`, + `{"billing":{"stripe":{"success_url":"https://console.example.test"}}}`, + } { + if _, err := Load(writeConfig(t, content)); err == nil { + t.Fatalf("expected literal external service value to be rejected: %s", content) + } + } +} + func TestLoadControlPlaneModeWithoutStaticRoutes(t *testing.T) { t.Setenv("AIGW_DATABASE_URL", "postgres://aigw:aigw@postgres/aigw") t.Setenv("AIGW_REDIS_URL", "redis://redis:6379/0") t.Setenv("AIGW_CREDENTIAL_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") t.Setenv("AIGW_ADMIN_TOKEN", "admin-secret") + t.Setenv("AIGW_PUBLIC_URL", "http://localhost:8080/admin/") path := writeConfig(t, `{ "control_plane": {"enabled":true}, "admin": {"enabled":true} @@ -90,9 +108,11 @@ func TestLoadResolvesStripeSecrets(t *testing.T) { t.Setenv("AIGW_CREDENTIAL_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") t.Setenv("TEST_STRIPE_KEY", "rk_test_example") t.Setenv("TEST_STRIPE_WEBHOOK", "whsec_example") + t.Setenv("TEST_STRIPE_SUCCESS", "https://console.example.test/admin/?topup=success") + t.Setenv("TEST_STRIPE_CANCEL", "https://console.example.test/admin/?topup=cancel") path := writeConfig(t, `{ "control_plane":{"enabled":true}, - "billing":{"enabled":true,"stripe":{"enabled":true,"api_key_env":"TEST_STRIPE_KEY","webhook_secret_env":"TEST_STRIPE_WEBHOOK","success_url":"http://localhost/admin/?topup=success","cancel_url":"http://localhost/admin/?topup=cancel"}} + "billing":{"enabled":true,"stripe":{"enabled":true,"api_key_env":"TEST_STRIPE_KEY","webhook_secret_env":"TEST_STRIPE_WEBHOOK","success_url_env":"TEST_STRIPE_SUCCESS","cancel_url_env":"TEST_STRIPE_CANCEL"}} }`) cfg, err := Load(path) if err != nil { @@ -101,6 +121,21 @@ func TestLoadResolvesStripeSecrets(t *testing.T) { if cfg.Billing.Stripe.APIKey != "rk_test_example" || cfg.Billing.Stripe.WebhookSecret != "whsec_example" { t.Fatal("Stripe secrets were not resolved") } + if cfg.Billing.Stripe.SuccessURL != "https://console.example.test/admin/?topup=success" || cfg.Billing.Stripe.CancelURL != "https://console.example.test/admin/?topup=cancel" { + t.Fatal("Stripe callback URLs were not resolved") + } +} + +func TestLoadRejectsEmptyExternalServiceEnvironment(t *testing.T) { + t.Setenv("TEST_UPSTREAM_KEY", "secret") + t.Setenv("TEST_UPSTREAM_URL", "") + path := writeConfig(t, `{ + "providers": [{"id":"primary","protocol":"openai","base_url_env":"TEST_UPSTREAM_URL","api_key_env":"TEST_UPSTREAM_KEY"}], + "models": [{"id":"example/model","routes":[{"provider":"primary","upstream_model":"model"}]}] +}`) + if _, err := Load(path); err == nil { + t.Fatal("expected empty provider URL environment variable to be rejected") + } } func TestLoadRejectsBillingWithoutControlPlane(t *testing.T) { @@ -110,6 +145,45 @@ func TestLoadRejectsBillingWithoutControlPlane(t *testing.T) { } } +func TestVersionedExamplesResolveExternalServicesFromEnvironment(t *testing.T) { + t.Setenv("AIGW_SERVER_ADDRESS", "127.0.0.1:18081") + t.Setenv("AIGW_API_KEYS", `[{"key":"test","key_id":"key","tenant_id":"tenant","project_id":"project","scopes":["inference"]}]`) + t.Setenv("OPENAI_BASE_URL", "https://openai.example.test/v1") + t.Setenv("OPENAI_API_KEY", "openai-secret") + t.Setenv("ANTHROPIC_BASE_URL", "https://anthropic.example.test/v1") + t.Setenv("ANTHROPIC_API_KEY", "anthropic-secret") + staticConfig, err := Load(filepath.Join("..", "..", "config.example.json")) + if err != nil { + t.Fatalf("load static example: %v", err) + } + if staticConfig.Providers[0].BaseURL != "https://openai.example.test/v1" { + t.Fatalf("static provider URL = %q", staticConfig.Providers[0].BaseURL) + } + + t.Setenv("AIGW_DATABASE_URL", "postgres://example:secret@postgres.example.test/aigw") + t.Setenv("AIGW_REDIS_URL", "redis://redis.example.test:6379/0") + t.Setenv("AIGW_CREDENTIAL_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + t.Setenv("AIGW_ADMIN_TOKEN", "bootstrap-secret") + t.Setenv("AIGW_PUBLIC_URL", "https://console.example.test/admin/") + t.Setenv("AIGW_SMTP_FROM_ADDRESS", "no-reply@example.test") + t.Setenv("AIGW_SMTP_ADDRESS", "smtp.example.test:587") + t.Setenv("AIGW_SMTP_USERNAME", "") + t.Setenv("AIGW_SMTP_PASSWORD", "") + t.Setenv("AIGW_WEBAUTHN_RP_ID", "console.example.test") + t.Setenv("AIGW_WEBAUTHN_ORIGINS", "https://console.example.test") + t.Setenv("AIGW_STRIPE_API_KEY", "rk_test_example") + t.Setenv("AIGW_STRIPE_WEBHOOK_SECRET", "whsec_example") + t.Setenv("AIGW_STRIPE_SUCCESS_URL", "https://console.example.test/admin/?topup=success") + t.Setenv("AIGW_STRIPE_CANCEL_URL", "https://console.example.test/admin/?topup=cancel") + controlConfig, err := Load(filepath.Join("..", "..", "config.control.example.json")) + if err != nil { + t.Fatalf("load control-plane example: %v", err) + } + if controlConfig.Billing.Stripe.SuccessURL != "https://console.example.test/admin/?topup=success" { + t.Fatalf("Stripe success URL = %q", controlConfig.Billing.Stripe.SuccessURL) + } +} + func writeConfig(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.json") diff --git a/internal/controlplane/access.go b/internal/controlplane/access.go index 0792e8b..47e9a8f 100644 --- a/internal/controlplane/access.go +++ b/internal/controlplane/access.go @@ -88,7 +88,11 @@ func (s *Store) AuthenticateConsoleToken(ctx context.Context, raw string) (Conso func (s *Store) ListConsoleUsers(ctx context.Context, tenantID string) ([]ConsoleUser, error) { query := `SELECT id::text, COALESCE(tenant_id::text, ''), email, display_name, role, - COALESCE(token_prefix, ''), password_hash IS NOT NULL, status, last_used_at, created_at FROM console_users` + COALESCE(token_prefix, ''), password_hash IS NOT NULL, status, email_verified_at IS NOT NULL, + ARRAY_REMOVE(ARRAY[ + CASE WHEN EXISTS(SELECT 1 FROM console_totp_credentials tc WHERE tc.user_id=console_users.id AND tc.confirmed_at IS NOT NULL) THEN 'totp' END, + CASE WHEN EXISTS(SELECT 1 FROM console_passkeys pk WHERE pk.user_id=console_users.id) THEN 'passkey' END + ],NULL), last_used_at, created_at FROM console_users` args := []any{} if tenantID != "" { query += ` WHERE tenant_id = $1` @@ -103,7 +107,8 @@ func (s *Store) ListConsoleUsers(ctx context.Context, tenantID string) ([]Consol result := make([]ConsoleUser, 0) for rows.Next() { var item ConsoleUser - if err := rows.Scan(&item.ID, &item.TenantID, &item.Email, &item.DisplayName, &item.Role, &item.TokenPrefix, &item.HasPassword, &item.Status, &item.LastUsedAt, &item.CreatedAt); err != nil { + if err := rows.Scan(&item.ID, &item.TenantID, &item.Email, &item.DisplayName, &item.Role, &item.TokenPrefix, &item.HasPassword, + &item.Status, &item.EmailVerified, &item.MFAMethods, &item.LastUsedAt, &item.CreatedAt); err != nil { return nil, fmt.Errorf("scan console user: %w", err) } result = append(result, item) @@ -217,20 +222,23 @@ func (s *Store) RegisterTenant(ctx context.Context, input RegisterInput, currenc func (s *Store) AuthenticateConsolePassword(ctx context.Context, input PasswordLoginInput, remoteIP string) (ConsoleActor, error) { email := strings.ToLower(strings.TrimSpace(input.Email)) - identity := sha256.Sum256([]byte(email + "\x00" + remoteIP)) - var lockedUntil *time.Time - err := s.db.QueryRow(ctx, `SELECT locked_until FROM console_login_throttles WHERE identity_hash=$1`, identity[:]).Scan(&lockedUntil) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return ConsoleActor{}, fmt.Errorf("read console login throttle: %w", err) - } - if lockedUntil != nil && lockedUntil.After(time.Now()) { - return ConsoleActor{}, ErrConsoleRateLimited + emailIdentity := sha256.Sum256([]byte("email\x00" + email)) + ipIdentity := sha256.Sum256([]byte("ip\x00" + remoteIP)) + for _, identity := range [][]byte{emailIdentity[:], ipIdentity[:]} { + var lockedUntil *time.Time + err := s.db.QueryRow(ctx, `SELECT locked_until FROM console_login_throttles WHERE identity_hash=$1`, identity).Scan(&lockedUntil) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return ConsoleActor{}, fmt.Errorf("read console login throttle: %w", err) + } + if lockedUntil != nil && lockedUntil.After(time.Now()) { + return ConsoleActor{}, ErrConsoleRateLimited + } } var actor ConsoleActor var expectedHash, salt []byte var iterations int - err = s.db.QueryRow(ctx, `SELECT u.id::text, COALESCE(u.tenant_id::text,''), u.email, u.display_name, u.role, + err := s.db.QueryRow(ctx, `SELECT u.id::text, COALESCE(u.tenant_id::text,''), u.email, u.display_name, u.role, u.password_hash, u.password_salt, u.password_iterations FROM console_users u LEFT JOIN tenants t ON t.id=u.tenant_id WHERE lower(u.email)=$1 AND u.status='active' AND u.password_hash IS NOT NULL @@ -240,7 +248,7 @@ func (s *Store) AuthenticateConsolePassword(ctx context.Context, input PasswordL dummyHash := make([]byte, security.PasswordHashBytes) dummySalt := make([]byte, security.PasswordSaltBytes) _ = security.VerifyPassword(input.Password, dummyHash, dummySalt, security.PasswordIterations) - if failureErr := s.recordLoginFailure(ctx, identity[:]); failureErr != nil { + if failureErr := s.recordLoginFailures(ctx, emailIdentity[:], ipIdentity[:]); failureErr != nil { return ConsoleActor{}, failureErr } return ConsoleActor{}, ErrConsoleUnauthorized @@ -249,27 +257,38 @@ func (s *Store) AuthenticateConsolePassword(ctx context.Context, input PasswordL return ConsoleActor{}, fmt.Errorf("query console login: %w", err) } if !security.VerifyPassword(input.Password, expectedHash, salt, iterations) { - if failureErr := s.recordLoginFailure(ctx, identity[:]); failureErr != nil { + if failureErr := s.recordLoginFailures(ctx, emailIdentity[:], ipIdentity[:]); failureErr != nil { return ConsoleActor{}, failureErr } return ConsoleActor{}, ErrConsoleUnauthorized } - if _, err := s.db.Exec(ctx, `DELETE FROM console_login_throttles WHERE identity_hash=$1`, identity[:]); err != nil { + if _, err := s.db.Exec(ctx, `DELETE FROM console_login_throttles WHERE identity_hash=$1`, emailIdentity[:]); err != nil { return ConsoleActor{}, fmt.Errorf("clear console login throttle: %w", err) } if _, err := s.db.Exec(ctx, `UPDATE console_users SET last_used_at=now() WHERE id=$1`, actor.ID); err != nil { return ConsoleActor{}, fmt.Errorf("update console login time: %w", err) } + actor.MFAMethods, err = s.MFAMethods(ctx, actor.ID) + if err != nil { + return ConsoleActor{}, err + } return actor, nil } -func (s *Store) recordLoginFailure(ctx context.Context, identityHash []byte) error { +func (s *Store) recordLoginFailures(ctx context.Context, emailIdentity, ipIdentity []byte) error { + if err := s.recordLoginFailure(ctx, emailIdentity, 5); err != nil { + return err + } + return s.recordLoginFailure(ctx, ipIdentity, 25) +} + +func (s *Store) recordLoginFailure(ctx context.Context, identityHash []byte, threshold int) error { _, err := s.db.Exec(ctx, `INSERT INTO console_login_throttles (identity_hash,failures) VALUES ($1,1) ON CONFLICT (identity_hash) DO UPDATE SET failures=CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN 1 ELSE console_login_throttles.failures+1 END, window_started_at=CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN now() ELSE console_login_throttles.window_started_at END, - locked_until=CASE WHEN (CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN 1 ELSE console_login_throttles.failures+1 END) >= 5 THEN now()+interval '15 minutes' ELSE NULL END, - updated_at=now()`, identityHash) + locked_until=CASE WHEN (CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN 1 ELSE console_login_throttles.failures+1 END) >= $2 THEN now()+interval '15 minutes' ELSE NULL END, + updated_at=now()`, identityHash, threshold) if err != nil { return fmt.Errorf("record console login failure: %w", err) } @@ -277,6 +296,10 @@ func (s *Store) recordLoginFailure(ctx context.Context, identityHash []byte) err } func (s *Store) CreateConsoleSession(ctx context.Context, actor ConsoleActor, ttl time.Duration, remoteIP, userAgent string) (ConsoleSession, error) { + return s.CreateConsoleSessionWithMethod(ctx, actor, ttl, remoteIP, userAgent, "password", false) +} + +func (s *Store) CreateConsoleSessionWithMethod(ctx context.Context, actor ConsoleActor, ttl time.Duration, remoteIP, userAgent, authMethod string, mfaVerified bool) (ConsoleSession, error) { if actor.ID == "" || ttl < time.Minute { return ConsoleSession{}, errors.New("session user and ttl are required") } @@ -289,12 +312,19 @@ func (s *Store) CreateConsoleSession(ctx context.Context, actor ConsoleActor, tt return ConsoleSession{}, err } expiresAt := time.Now().UTC().Add(ttl) - _, err = s.db.Exec(ctx, `INSERT INTO console_sessions (user_id,token_hash,csrf_hash,expires_at,remote_ip,user_agent) - VALUES ($1,$2,$3,$4,NULLIF($5,'')::inet,$6)`, actor.ID, tokenHash, csrfHash, expiresAt, remoteIP, userAgent) + var sessionID string + var mfaVerifiedAt any + if mfaVerified { + mfaVerifiedAt = time.Now().UTC() + } + err = s.db.QueryRow(ctx, `INSERT INTO console_sessions + (user_id,token_hash,csrf_hash,expires_at,remote_ip,user_agent,auth_method,mfa_verified_at) + VALUES ($1,$2,$3,$4,NULLIF($5,'')::inet,$6,$7,$8) RETURNING id::text`, + actor.ID, tokenHash, csrfHash, expiresAt, remoteIP, userAgent, authMethod, mfaVerifiedAt).Scan(&sessionID) if err != nil { return ConsoleSession{}, fmt.Errorf("create console session: %w", err) } - return ConsoleSession{Actor: actor, Token: token, CSRFToken: csrf, ExpiresAt: expiresAt}, nil + return ConsoleSession{Actor: actor, Token: token, CSRFToken: csrf, ExpiresAt: expiresAt, ID: sessionID, AuthMethod: authMethod}, nil } func (s *Store) AuthenticateConsoleSession(ctx context.Context, rawToken string) (ConsoleActor, []byte, error) { @@ -313,6 +343,10 @@ func (s *Store) AuthenticateConsoleSession(ctx context.Context, rawToken string) if err != nil { return ConsoleActor{}, nil, fmt.Errorf("authenticate console session: %w", err) } + actor.MFAMethods, err = s.MFAMethods(ctx, actor.ID) + if err != nil { + return ConsoleActor{}, nil, err + } return actor, csrfHash, nil } @@ -325,6 +359,46 @@ func (s *Store) RevokeConsoleSession(ctx context.Context, rawToken string) error return err } +func (s *Store) ListDeviceSessions(ctx context.Context, actorID, currentToken string) ([]DeviceSession, error) { + currentHash := sha256.Sum256([]byte(currentToken)) + rows, err := s.db.Query(ctx, `SELECT id::text,COALESCE(host(remote_ip),''),user_agent,auth_method,mfa_verified_at, + created_at,last_seen_at,expires_at,token_hash=$2 FROM console_sessions + WHERE user_id=$1 AND revoked_at IS NULL AND expires_at>now() ORDER BY last_seen_at DESC`, actorID, currentHash[:]) + if err != nil { + return nil, fmt.Errorf("list console sessions: %w", err) + } + defer rows.Close() + result := make([]DeviceSession, 0) + for rows.Next() { + var item DeviceSession + if err := rows.Scan(&item.ID, &item.RemoteIP, &item.UserAgent, &item.AuthMethod, &item.MFAVerifiedAt, + &item.CreatedAt, &item.LastSeenAt, &item.ExpiresAt, &item.Current); err != nil { + return nil, err + } + result = append(result, item) + } + return result, rows.Err() +} + +func (s *Store) RevokeDeviceSession(ctx context.Context, actorID, sessionID string) (bool, error) { + tag, err := s.db.Exec(ctx, `UPDATE console_sessions SET revoked_at=now() + WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, sessionID, actorID) + if err != nil { + return false, err + } + if tag.RowsAffected() == 0 { + return false, ErrNotFound + } + return true, nil +} + +func (s *Store) RevokeOtherDeviceSessions(ctx context.Context, actorID, currentToken string) error { + currentHash := sha256.Sum256([]byte(currentToken)) + _, err := s.db.Exec(ctx, `UPDATE console_sessions SET revoked_at=now() + WHERE user_id=$1 AND token_hash<>$2 AND revoked_at IS NULL`, actorID, currentHash[:]) + return err +} + func (s *Store) ChangeConsolePassword(ctx context.Context, actorID string, input PasswordChangeInput) error { var expectedHash, salt []byte var iterations int diff --git a/internal/controlplane/identity.go b/internal/controlplane/identity.go new file mode 100644 index 0000000..07fe0d7 --- /dev/null +++ b/internal/controlplane/identity.go @@ -0,0 +1,291 @@ +package controlplane + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "net/mail" + "strings" + "time" + + "aigw/internal/security" + + "github.com/jackc/pgx/v5" +) + +var ( + ErrActionTokenInvalid = errors.New("account action link is invalid or expired") + ErrMFARequired = errors.New("multi-factor authentication is required") +) + +func (s *Store) RegisterTenantPending(ctx context.Context, input RegisterInput, currency, publicURL, remoteIP string) (ConsoleActor, int64, error) { + input.Organization = strings.TrimSpace(input.Organization) + input.TenantSlug = strings.ToLower(strings.TrimSpace(input.TenantSlug)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + if input.Organization == "" || input.DisplayName == "" || !slugPattern.MatchString(input.TenantSlug) || !validEmail(input.Email) { + return ConsoleActor{}, 0, errors.New("registration requires organization, a valid tenant_slug, display_name, and email") + } + if len(currency) != 3 { + return ConsoleActor{}, 0, errors.New("registration currency is invalid") + } + hash, salt, iterations, err := security.HashPassword(input.Password) + if err != nil { + return ConsoleActor{}, 0, err + } + tx, err := s.db.Begin(ctx) + if err != nil { + return ConsoleActor{}, 0, err + } + defer tx.Rollback(ctx) + var tenantID string + if err := tx.QueryRow(ctx, `INSERT INTO tenants (slug,name) VALUES ($1,$2) RETURNING id::text`, input.TenantSlug, input.Organization).Scan(&tenantID); err != nil { + return ConsoleActor{}, 0, fmt.Errorf("create registered tenant: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO projects (tenant_id,slug,name) VALUES ($1,'default','Default project')`, tenantID); err != nil { + return ConsoleActor{}, 0, fmt.Errorf("create default project: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO tenant_wallets (tenant_id,currency) VALUES ($1,$2)`, tenantID, strings.ToLower(currency)); err != nil { + return ConsoleActor{}, 0, fmt.Errorf("create tenant wallet: %w", err) + } + actor := ConsoleActor{TenantID: tenantID, Email: input.Email, DisplayName: input.DisplayName, Role: RoleTenantAdmin} + if err := tx.QueryRow(ctx, `INSERT INTO console_users + (tenant_id,email,display_name,role,password_hash,password_salt,password_iterations,password_changed_at,status) + VALUES ($1,$2,$3,$4,$5,$6,$7,now(),'pending_verification') RETURNING id::text`, tenantID, input.Email, + input.DisplayName, RoleTenantAdmin, hash, salt, iterations).Scan(&actor.ID); err != nil { + return ConsoleActor{}, 0, fmt.Errorf("create pending tenant administrator: %w", err) + } + if _, err := s.issueActionToken(ctx, tx, actor.ID, actor.Email, actor.DisplayName, actionVerifyEmail, publicURL, remoteIP, 24*time.Hour); err != nil { + return ConsoleActor{}, 0, err + } + generation, err := bumpGeneration(ctx, tx) + if err != nil { + return ConsoleActor{}, 0, err + } + if err := tx.Commit(ctx); err != nil { + return ConsoleActor{}, 0, err + } + return actor, generation, nil +} + +func (s *Store) InviteConsoleUser(ctx context.Context, input CreateConsoleUserInput, publicURL, remoteIP string) (CreatedConsoleUser, error) { + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.TenantID = strings.TrimSpace(input.TenantID) + address, addressErr := mail.ParseAddress(input.Email) + if addressErr != nil || address.Address != input.Email || input.DisplayName == "" { + return CreatedConsoleUser{}, errors.New("invitation requires a valid email and display_name") + } + if err := validateConsoleRole(input.Role, input.TenantID); err != nil { + return CreatedConsoleUser{}, err + } + tx, err := s.db.Begin(ctx) + if err != nil { + return CreatedConsoleUser{}, err + } + defer tx.Rollback(ctx) + var result CreatedConsoleUser + err = tx.QueryRow(ctx, `INSERT INTO console_users + (tenant_id,email,display_name,role,status,invited_at) + VALUES (NULLIF($1,'')::uuid,$2,$3,$4,'invited',now()) + RETURNING id::text,COALESCE(tenant_id::text,''),email,display_name,role,COALESCE(token_prefix,''), + password_hash IS NOT NULL,status,FALSE,'{}'::text[],last_used_at,created_at`, + input.TenantID, input.Email, input.DisplayName, input.Role, + ).Scan(&result.ID, &result.TenantID, &result.Email, &result.DisplayName, &result.Role, &result.TokenPrefix, + &result.HasPassword, &result.Status, &result.EmailVerified, &result.MFAMethods, &result.LastUsedAt, &result.CreatedAt) + if err != nil { + return CreatedConsoleUser{}, fmt.Errorf("create invited console user: %w", err) + } + if _, err := s.issueActionToken(ctx, tx, result.ID, result.Email, result.DisplayName, actionInvite, publicURL, remoteIP, 7*24*time.Hour); err != nil { + return CreatedConsoleUser{}, err + } + if err := tx.Commit(ctx); err != nil { + return CreatedConsoleUser{}, err + } + return result, nil +} + +func validateConsoleRole(role, tenantID string) error { + platform := role == RolePlatformAdmin || role == RolePlatformViewer + tenant := role == RoleTenantAdmin || role == RoleTenantBilling || role == RoleTenantDeveloper || role == RoleTenantViewer + if (!platform && !tenant) || (platform && tenantID != "") || (tenant && tenantID == "") { + return errors.New("console user role and tenant_id are inconsistent") + } + return nil +} + +func (s *Store) VerifyEmailToken(ctx context.Context, rawToken string) (ConsoleActor, error) { + tokenHash := sha256.Sum256([]byte(strings.TrimSpace(rawToken))) + tx, err := s.db.Begin(ctx) + if err != nil { + return ConsoleActor{}, err + } + defer tx.Rollback(ctx) + var tokenID string + var actor ConsoleActor + err = tx.QueryRow(ctx, `SELECT a.id::text,u.id::text,COALESCE(u.tenant_id::text,''),u.email,u.display_name,u.role + FROM console_action_tokens a JOIN console_users u ON u.id=a.user_id + WHERE a.token_hash=$1 AND a.purpose='verify_email' AND a.consumed_at IS NULL AND a.expires_at>now() + AND u.status='pending_verification' FOR UPDATE OF a,u`, tokenHash[:], + ).Scan(&tokenID, &actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role) + if errors.Is(err, pgx.ErrNoRows) { + return ConsoleActor{}, ErrActionTokenInvalid + } + if err != nil { + return ConsoleActor{}, err + } + if _, err := tx.Exec(ctx, `UPDATE console_action_tokens SET consumed_at=now() WHERE id=$1`, tokenID); err != nil { + return ConsoleActor{}, err + } + if _, err := tx.Exec(ctx, `UPDATE console_users SET status='active',email_verified_at=now(),accepted_at=COALESCE(accepted_at,now()) WHERE id=$1`, actor.ID); err != nil { + return ConsoleActor{}, err + } + if err := tx.Commit(ctx); err != nil { + return ConsoleActor{}, err + } + return actor, nil +} + +func (s *Store) ResendVerification(ctx context.Context, email, publicURL, remoteIP string) error { + email = strings.ToLower(strings.TrimSpace(email)) + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + var id, displayName string + err = tx.QueryRow(ctx, `SELECT id::text,display_name FROM console_users + WHERE lower(email)=$1 AND status='pending_verification' FOR UPDATE`, email).Scan(&id, &displayName) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if _, err := s.issueActionToken(ctx, tx, id, email, displayName, actionVerifyEmail, publicURL, remoteIP, 24*time.Hour); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Store) RequestPasswordReset(ctx context.Context, email, publicURL, remoteIP string) error { + email = strings.ToLower(strings.TrimSpace(email)) + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + var id, displayName string + err = tx.QueryRow(ctx, `SELECT id::text,display_name FROM console_users + WHERE lower(email)=$1 AND status='active' AND email_verified_at IS NOT NULL FOR UPDATE`, email).Scan(&id, &displayName) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if _, err := s.issueActionToken(ctx, tx, id, email, displayName, actionPasswordReset, publicURL, remoteIP, 30*time.Minute); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Store) ResetPassword(ctx context.Context, input PasswordResetInput) error { + hash, salt, iterations, err := security.HashPassword(input.NewPassword) + if err != nil { + return err + } + tokenHash := sha256.Sum256([]byte(strings.TrimSpace(input.Token))) + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + var tokenID, userID string + err = tx.QueryRow(ctx, `SELECT a.id::text,u.id::text FROM console_action_tokens a + JOIN console_users u ON u.id=a.user_id WHERE a.token_hash=$1 AND a.purpose='password_reset' + AND a.consumed_at IS NULL AND a.expires_at>now() AND u.status='active' FOR UPDATE OF a,u`, tokenHash[:]).Scan(&tokenID, &userID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrActionTokenInvalid + } + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE console_users SET password_hash=$2,password_salt=$3,password_iterations=$4,password_changed_at=now() WHERE id=$1`, userID, hash, salt, iterations); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE console_action_tokens SET consumed_at=now() WHERE id=$1`, tokenID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE console_sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, userID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE console_auth_challenges SET consumed_at=now() WHERE user_id=$1 AND consumed_at IS NULL`, userID); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Store) AcceptInvite(ctx context.Context, input InviteAcceptInput) (ConsoleActor, error) { + hash, salt, iterations, err := security.HashPassword(input.Password) + if err != nil { + return ConsoleActor{}, err + } + input.DisplayName = strings.TrimSpace(input.DisplayName) + if input.DisplayName == "" { + return ConsoleActor{}, errors.New("display_name is required") + } + tokenHash := sha256.Sum256([]byte(strings.TrimSpace(input.Token))) + tx, err := s.db.Begin(ctx) + if err != nil { + return ConsoleActor{}, err + } + defer tx.Rollback(ctx) + var tokenID string + var actor ConsoleActor + err = tx.QueryRow(ctx, `SELECT a.id::text,u.id::text,COALESCE(u.tenant_id::text,''),u.email,u.role + FROM console_action_tokens a JOIN console_users u ON u.id=a.user_id + WHERE a.token_hash=$1 AND a.purpose='invite' AND a.consumed_at IS NULL AND a.expires_at>now() + AND u.status='invited' FOR UPDATE OF a,u`, tokenHash[:], + ).Scan(&tokenID, &actor.ID, &actor.TenantID, &actor.Email, &actor.Role) + if errors.Is(err, pgx.ErrNoRows) { + return ConsoleActor{}, ErrActionTokenInvalid + } + if err != nil { + return ConsoleActor{}, err + } + actor.DisplayName = input.DisplayName + if _, err := tx.Exec(ctx, `UPDATE console_users SET display_name=$2,password_hash=$3,password_salt=$4, + password_iterations=$5,password_changed_at=now(),status='active',email_verified_at=now(),accepted_at=now() + WHERE id=$1`, actor.ID, actor.DisplayName, hash, salt, iterations); err != nil { + return ConsoleActor{}, err + } + if _, err := tx.Exec(ctx, `UPDATE console_action_tokens SET consumed_at=now() WHERE id=$1`, tokenID); err != nil { + return ConsoleActor{}, err + } + if err := tx.Commit(ctx); err != nil { + return ConsoleActor{}, err + } + return actor, nil +} + +func (s *Store) ConsumeRateLimit(ctx context.Context, scope, key string, limit int, window time.Duration) error { + if limit < 1 || window < time.Second { + return errors.New("invalid rate limit") + } + bucket := sha256.Sum256([]byte(scope + "\x00" + strings.ToLower(strings.TrimSpace(key)))) + var hits int + err := s.db.QueryRow(ctx, `INSERT INTO console_rate_limits (bucket_hash,hits) VALUES ($1,1) + ON CONFLICT (bucket_hash) DO UPDATE SET + hits=CASE WHEN console_rate_limits.window_started_at < now()-($2::bigint * interval '1 millisecond') THEN 1 ELSE console_rate_limits.hits+1 END, + window_started_at=CASE WHEN console_rate_limits.window_started_at < now()-($2::bigint * interval '1 millisecond') THEN now() ELSE console_rate_limits.window_started_at END, + updated_at=now() RETURNING hits`, bucket[:], window.Milliseconds()).Scan(&hits) + if err != nil { + return fmt.Errorf("consume account rate limit: %w", err) + } + if hits > limit { + return ErrConsoleRateLimited + } + return nil +} diff --git a/internal/controlplane/mfa.go b/internal/controlplane/mfa.go new file mode 100644 index 0000000..c96cac1 --- /dev/null +++ b/internal/controlplane/mfa.go @@ -0,0 +1,344 @@ +package controlplane + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "image/png" + "strings" + "time" + + "aigw/internal/security" + + "github.com/jackc/pgx/v5" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +func (s *Store) MFAMethods(ctx context.Context, userID string) ([]string, error) { + var totpEnabled, passkeyEnabled, recoveryEnabled bool + if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM console_totp_credentials WHERE user_id=$1 AND confirmed_at IS NOT NULL), + EXISTS(SELECT 1 FROM console_passkeys WHERE user_id=$1), + EXISTS(SELECT 1 FROM console_recovery_codes WHERE user_id=$1 AND used_at IS NULL)`, userID). + Scan(&totpEnabled, &passkeyEnabled, &recoveryEnabled); err != nil { + return nil, err + } + methods := make([]string, 0, 3) + if totpEnabled { + methods = append(methods, "totp") + } + if passkeyEnabled { + methods = append(methods, "passkey") + } + if recoveryEnabled { + methods = append(methods, "recovery") + } + return methods, nil +} + +func (s *Store) MFAStatus(ctx context.Context, userID string) (MFAStatus, error) { + var result MFAStatus + if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM console_totp_credentials + WHERE user_id=$1 AND confirmed_at IS NOT NULL)`, userID).Scan(&result.TOTPEnabled); err != nil { + return MFAStatus{}, err + } + passkeys, err := s.ListPasskeys(ctx, userID) + if err != nil { + return MFAStatus{}, err + } + result.Passkeys = passkeys + return result, nil +} + +func (s *Store) FindActiveConsoleUser(ctx context.Context, email string) (ConsoleActor, error) { + var actor ConsoleActor + err := s.db.QueryRow(ctx, `SELECT id::text,COALESCE(tenant_id::text,''),email,display_name,role + FROM console_users WHERE lower(email)=$1 AND status='active' AND email_verified_at IS NOT NULL`, + strings.ToLower(strings.TrimSpace(email))).Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role) + if errors.Is(err, pgx.ErrNoRows) { + return ConsoleActor{}, ErrConsoleUnauthorized + } + return actor, err +} + +func (s *Store) VerifyConsolePassword(ctx context.Context, userID, password string) error { + var expectedHash, salt []byte + var iterations int + if err := s.db.QueryRow(ctx, `SELECT password_hash,password_salt,password_iterations FROM console_users + WHERE id=$1 AND status='active'`, userID).Scan(&expectedHash, &salt, &iterations); err != nil { + return ErrConsoleUnauthorized + } + if !security.VerifyPassword(password, expectedHash, salt, iterations) { + return ErrConsoleUnauthorized + } + return nil +} + +func (s *Store) ResolveMFAChallenge(ctx context.Context, rawToken, remoteIP string) (string, string, error) { + hash := sha256.Sum256([]byte(strings.TrimSpace(rawToken))) + var id, userID, expectedIP string + err := s.db.QueryRow(ctx, `SELECT id::text,user_id::text,COALESCE(host(remote_ip),'') + FROM console_auth_challenges WHERE token_hash=$1 AND purpose='mfa_login' + AND consumed_at IS NULL AND expires_at>now()`, hash[:]).Scan(&id, &userID, &expectedIP) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", ErrActionTokenInvalid + } + if err != nil { + return "", "", err + } + if expectedIP != "" && remoteIP != "" && expectedIP != remoteIP { + return "", "", ErrActionTokenInvalid + } + return id, userID, nil +} + +func (s *Store) VerifyTOTPForUser(ctx context.Context, userID, code string) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + var ciphertext []byte + var lastStep int64 + if err := tx.QueryRow(ctx, `SELECT secret_ciphertext,last_used_step FROM console_totp_credentials + WHERE user_id=$1 AND confirmed_at IS NOT NULL FOR UPDATE`, userID).Scan(&ciphertext, &lastStep); err != nil { + return ErrConsoleUnauthorized + } + secret, err := s.cipher.Decrypt(ciphertext) + if err != nil { + return err + } + step, ok := matchingTOTPStep(secret, strings.TrimSpace(code), time.Now().UTC()) + if !ok || step <= lastStep { + return ErrConsoleUnauthorized + } + if _, err := tx.Exec(ctx, `UPDATE console_totp_credentials SET last_used_step=$2 WHERE user_id=$1`, userID, step); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Store) BeginMFAChallenge(ctx context.Context, actor ConsoleActor, remoteIP, userAgent string) (AuthChallenge, error) { + methods, err := s.MFAMethods(ctx, actor.ID) + if err != nil { + return AuthChallenge{}, err + } + if len(methods) == 0 { + return AuthChallenge{}, ErrActionTokenInvalid + } + raw, hash, err := randomCredential("mfa-aigw-") + if err != nil { + return AuthChallenge{}, err + } + expires := time.Now().UTC().Add(10 * time.Minute) + _, err = s.db.Exec(ctx, `INSERT INTO console_auth_challenges + (user_id,token_hash,purpose,allowed_methods,expires_at,remote_ip,user_agent) + VALUES ($1,$2,'mfa_login',$3,$4,NULLIF($5,'')::inet,$6)`, actor.ID, hash, methods, expires, remoteIP, userAgent) + if err != nil { + return AuthChallenge{}, fmt.Errorf("create MFA challenge: %w", err) + } + return AuthChallenge{Token: raw, Methods: methods, ExpiresAt: expires}, nil +} + +func (s *Store) BeginTOTP(ctx context.Context, actor ConsoleActor) (TOTPEnrollment, error) { + key, err := totp.Generate(totp.GenerateOpts{Issuer: "AIGW", AccountName: actor.Email, Period: 30, SecretSize: 20, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1}) + if err != nil { + return TOTPEnrollment{}, fmt.Errorf("generate TOTP secret: %w", err) + } + ciphertext, err := s.cipher.Encrypt(key.Secret()) + if err != nil { + return TOTPEnrollment{}, err + } + result, err := s.db.Exec(ctx, `INSERT INTO console_totp_credentials (user_id,secret_ciphertext,confirmed_at,last_used_step) + VALUES ($1,$2,NULL,-1) ON CONFLICT (user_id) DO UPDATE SET secret_ciphertext=EXCLUDED.secret_ciphertext, + confirmed_at=NULL,last_used_step=-1,created_at=now() + WHERE console_totp_credentials.confirmed_at IS NULL`, actor.ID, ciphertext) + if err != nil { + return TOTPEnrollment{}, fmt.Errorf("store TOTP enrollment: %w", err) + } + if result.RowsAffected() == 0 { + return TOTPEnrollment{}, errors.New("TOTP is already enabled") + } + var image bytes.Buffer + qr, err := key.Image(256, 256) + if err == nil { + err = png.Encode(&image, qr) + } + if err != nil { + return TOTPEnrollment{}, fmt.Errorf("render TOTP QR: %w", err) + } + return TOTPEnrollment{Secret: key.Secret(), URI: key.URL(), QRCode: "data:image/png;base64," + base64.StdEncoding.EncodeToString(image.Bytes())}, nil +} + +func (s *Store) ConfirmTOTP(ctx context.Context, actorID, code string) ([]string, error) { + code = strings.TrimSpace(code) + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + var ciphertext []byte + var confirmed *time.Time + if err := tx.QueryRow(ctx, `SELECT secret_ciphertext,confirmed_at FROM console_totp_credentials WHERE user_id=$1 FOR UPDATE`, actorID).Scan(&ciphertext, &confirmed); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("TOTP enrollment has not started") + } + return nil, err + } + secret, err := s.cipher.Decrypt(ciphertext) + if err != nil { + return nil, err + } + step, ok := matchingTOTPStep(secret, code, time.Now().UTC()) + if !ok { + return nil, errors.New("invalid TOTP code") + } + if confirmed != nil { + return nil, errors.New("TOTP is already enabled") + } + if _, err := tx.Exec(ctx, `UPDATE console_totp_credentials SET confirmed_at=now(),last_used_step=$2 WHERE user_id=$1`, actorID, step); err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, `DELETE FROM console_recovery_codes WHERE user_id=$1`, actorID); err != nil { + return nil, err + } + codes := make([]string, 0, 10) + for i := 0; i < 10; i++ { + code, hash, err := newRecoveryCode() + if err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, `INSERT INTO console_recovery_codes (user_id,code_hash) VALUES ($1,$2)`, actorID, hash); err != nil { + return nil, err + } + codes = append(codes, code) + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return codes, nil +} + +func (s *Store) DisableTOTP(ctx context.Context, actorID string) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `DELETE FROM console_totp_credentials WHERE user_id=$1`, actorID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM console_recovery_codes WHERE user_id=$1`, actorID); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Store) CompleteMFAChallenge(ctx context.Context, input MFACodeInput, remoteIP string) (ConsoleActor, string, error) { + code := strings.TrimSpace(input.Code) + tokenHash := sha256.Sum256([]byte(strings.TrimSpace(input.ChallengeToken))) + tx, err := s.db.Begin(ctx) + if err != nil { + return ConsoleActor{}, "", err + } + defer tx.Rollback(ctx) + var challengeID, userID, expectedIP string + var allowed []string + err = tx.QueryRow(ctx, `SELECT c.id::text,c.user_id::text,COALESCE(host(c.remote_ip),''),c.allowed_methods + FROM console_auth_challenges c WHERE c.token_hash=$1 AND c.purpose='mfa_login' + AND c.consumed_at IS NULL AND c.expires_at>now() FOR UPDATE`, tokenHash[:]).Scan(&challengeID, &userID, &expectedIP, &allowed) + if errors.Is(err, pgx.ErrNoRows) { + return ConsoleActor{}, "", ErrActionTokenInvalid + } + if err != nil { + return ConsoleActor{}, "", err + } + if expectedIP != "" && remoteIP != "" && expectedIP != remoteIP { + return ConsoleActor{}, "", ErrActionTokenInvalid + } + var method string + if method = "totp"; len(code) != 6 { + method = "recovery" + } + valid := false + if method == "totp" { + var ciphertext []byte + var lastStep int64 + if err := tx.QueryRow(ctx, `SELECT secret_ciphertext,last_used_step FROM console_totp_credentials WHERE user_id=$1 AND confirmed_at IS NOT NULL FOR UPDATE`, userID).Scan(&ciphertext, &lastStep); err == nil { + secret, decryptErr := s.cipher.Decrypt(ciphertext) + if decryptErr == nil { + if step, ok := matchingTOTPStep(secret, code, time.Now().UTC()); ok && step > lastStep { + valid = true + if _, err := tx.Exec(ctx, `UPDATE console_totp_credentials SET last_used_step=$2 WHERE user_id=$1`, userID, step); err != nil { + return ConsoleActor{}, "", err + } + } + } + } + } + if !valid && method == "recovery" { + hash := sha256.Sum256([]byte(normalizeRecoveryCode(code))) + result, updateErr := tx.Exec(ctx, `UPDATE console_recovery_codes SET used_at=now() + WHERE user_id=$1 AND code_hash=$2 AND used_at IS NULL`, userID, hash[:]) + valid = updateErr == nil && result.RowsAffected() == 1 + } + if !valid { + _, _ = tx.Exec(ctx, `UPDATE console_auth_challenges SET attempts=attempts+1, + consumed_at=CASE WHEN attempts+1 >= 5 THEN now() ELSE consumed_at END WHERE id=$1`, challengeID) + if err := tx.Commit(ctx); err != nil { + return ConsoleActor{}, "", err + } + return ConsoleActor{}, "", ErrConsoleUnauthorized + } + if _, err := tx.Exec(ctx, `UPDATE console_auth_challenges SET consumed_at=now() WHERE id=$1`, challengeID); err != nil { + return ConsoleActor{}, "", err + } + var actor ConsoleActor + if err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(tenant_id::text,''),email,display_name,role FROM console_users WHERE id=$1 AND status='active'`, userID). + Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role); err != nil { + return ConsoleActor{}, "", err + } + if err := tx.Commit(ctx); err != nil { + return ConsoleActor{}, "", err + } + return actor, method, nil +} + +func matchingTOTPStep(secret, code string, now time.Time) (int64, bool) { + if len(strings.TrimSpace(code)) != 6 { + return 0, false + } + current := now.Unix() / 30 + opts := totp.ValidateOpts{Period: 30, Skew: 0, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1} + for _, step := range []int64{current - 1, current, current + 1} { + generated, err := totp.GenerateCodeCustom(secret, time.Unix(step*30, 0), opts) + if err == nil && generated == code { + return step, true + } + } + return 0, false +} + +func newRecoveryCode() (string, []byte, error) { + raw, _, err := randomCredential("") + if err != nil { + return "", nil, err + } + value := strings.ToUpper(strings.TrimRight(raw, "=")) + if len(value) > 10 { + value = value[:10] + } + if len(value) < 10 { + return newRecoveryCode() + } + code := value[:5] + "-" + value[5:] + hash := sha256.Sum256([]byte(normalizeRecoveryCode(code))) + return code, hash[:], nil +} + +func normalizeRecoveryCode(value string) string { + return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), "-", "")) +} diff --git a/internal/controlplane/outbox.go b/internal/controlplane/outbox.go new file mode 100644 index 0000000..b54a064 --- /dev/null +++ b/internal/controlplane/outbox.go @@ -0,0 +1,152 @@ +package controlplane + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "aigw/internal/mailer" + + "github.com/jackc/pgx/v5" +) + +const ( + actionVerifyEmail = "verify_email" + actionPasswordReset = "password_reset" + actionInvite = "invite" +) + +func (s *Store) issueActionToken(ctx context.Context, tx pgx.Tx, userID, email, displayName, purpose, publicURL, remoteIP string, ttl time.Duration) (string, error) { + token, tokenHash, err := randomCredential("act-aigw-") + if err != nil { + return "", err + } + if _, err := tx.Exec(ctx, `UPDATE console_action_tokens SET consumed_at=now() + WHERE user_id=$1 AND purpose=$2 AND consumed_at IS NULL`, userID, purpose); err != nil { + return "", fmt.Errorf("expire previous account action: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO console_action_tokens + (user_id,purpose,token_hash,expires_at,requested_ip) VALUES ($1,$2,$3,$4,NULLIF($5,'')::inet)`, + userID, purpose, tokenHash, time.Now().UTC().Add(ttl), remoteIP); err != nil { + return "", fmt.Errorf("create account action: %w", err) + } + link, err := accountActionURL(publicURL, purpose, token) + if err != nil { + return "", err + } + subject, body := actionMail(displayName, purpose, link, ttl) + ciphertext, err := s.cipher.Encrypt(body) + if err != nil { + return "", fmt.Errorf("encrypt account email: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO console_mail_outbox + (recipient,template,subject,body_ciphertext) VALUES ($1,$2,$3,$4)`, email, purpose, subject, ciphertext); err != nil { + return "", fmt.Errorf("queue account email: %w", err) + } + return token, nil +} + +func accountActionURL(publicURL, purpose, token string) (string, error) { + parsed, err := url.Parse(publicURL) + if err != nil || parsed.Host == "" { + return "", errors.New("console public URL is invalid") + } + query := parsed.Query() + switch purpose { + case actionVerifyEmail: + query.Set("action", "verify-email") + case actionPasswordReset: + query.Set("action", "reset-password") + case actionInvite: + query.Set("action", "accept-invite") + default: + return "", errors.New("unsupported account action") + } + query.Set("token", token) + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func actionMail(displayName, purpose, link string, ttl time.Duration) (string, string) { + name := strings.TrimSpace(displayName) + if name == "" { + name = "there" + } + minutes := int(ttl.Minutes()) + expires := fmt.Sprintf("%d minutes", minutes) + if minutes%1440 == 0 { + expires = durationLabel(minutes/1440, "day") + } else if minutes%60 == 0 { + expires = durationLabel(minutes/60, "hour") + } + switch purpose { + case actionVerifyEmail: + return "Verify your AIGW email", fmt.Sprintf("Hi %s,\n\nVerify your email to activate your AIGW workspace:\n\n%s\n\nThis link expires in %s and can be used once. If you did not create this account, ignore this email.\n", name, link, expires) + case actionPasswordReset: + return "Reset your AIGW password", fmt.Sprintf("Hi %s,\n\nReset your AIGW password:\n\n%s\n\nThis link expires in %s and can be used once. If you did not request this, your password has not changed.\n", name, link, expires) + default: + return "You have been invited to AIGW", fmt.Sprintf("Hi %s,\n\nAccept your AIGW invitation and choose a password:\n\n%s\n\nThis link expires in %s and can be used once.\n", name, link, expires) + } +} + +func durationLabel(value int, unit string) string { + if value != 1 { + unit += "s" + } + return fmt.Sprintf("%d %s", value, unit) +} + +func (s *Store) ClaimMail(ctx context.Context) (mailer.Message, bool, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return mailer.Message{}, false, err + } + defer tx.Rollback(ctx) + var message mailer.Message + var ciphertext []byte + err = tx.QueryRow(ctx, `WITH candidate AS ( + SELECT id FROM console_mail_outbox + WHERE ((status IN ('pending','failed') AND available_at <= now()) + OR (status='sending' AND claimed_at < now()-interval '5 minutes')) + ORDER BY available_at,created_at FOR UPDATE SKIP LOCKED LIMIT 1 + ) UPDATE console_mail_outbox o SET status='sending',claimed_at=now(),attempts=attempts+1,last_error='' + FROM candidate WHERE o.id=candidate.id + RETURNING o.id::text,o.recipient,o.subject,o.body_ciphertext`, + ).Scan(&message.ID, &message.Recipient, &message.Subject, &ciphertext) + if errors.Is(err, pgx.ErrNoRows) { + return mailer.Message{}, false, nil + } + if err != nil { + return mailer.Message{}, false, fmt.Errorf("claim mail outbox item: %w", err) + } + message.Body, err = s.cipher.Decrypt(ciphertext) + if err != nil { + return mailer.Message{}, false, fmt.Errorf("decrypt mail outbox item: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return mailer.Message{}, false, err + } + return message, true, nil +} + +func (s *Store) MarkMailSent(ctx context.Context, id string) error { + _, err := s.db.Exec(ctx, `UPDATE console_mail_outbox SET status='sent',sent_at=now(),claimed_at=NULL,last_error='' + WHERE id=$1 AND status='sending'`, id) + return err +} + +func (s *Store) MarkMailFailed(ctx context.Context, id string, deliveryErr error) error { + message := "delivery failed" + if deliveryErr != nil { + message = deliveryErr.Error() + } + if len(message) > 1000 { + message = message[:1000] + } + _, err := s.db.Exec(ctx, `UPDATE console_mail_outbox SET status='failed',claimed_at=NULL,last_error=$2, + available_at=now()+make_interval(secs => LEAST(300, 5 * attempts)) WHERE id=$1 AND status='sending'`, id, message) + return err +} diff --git a/internal/controlplane/outbox_test.go b/internal/controlplane/outbox_test.go new file mode 100644 index 0000000..d9a0f2d --- /dev/null +++ b/internal/controlplane/outbox_test.go @@ -0,0 +1,33 @@ +package controlplane + +import ( + "net/url" + "strings" + "testing" + "time" +) + +func TestAccountActionURLKeepsAdminPathAndEncodesToken(t *testing.T) { + link, err := accountActionURL("https://console.example.test/admin/", actionVerifyEmail, "act-aigw-a+b/c") + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(link) + if err != nil { + t.Fatal(err) + } + if parsed.Path != "/admin/" || parsed.Query().Get("action") != "verify-email" || parsed.Query().Get("token") != "act-aigw-a+b/c" { + t.Fatalf("unexpected action URL %q", link) + } +} + +func TestActionMailUsesSingularDuration(t *testing.T) { + _, dayBody := actionMail("Test", actionVerifyEmail, "https://example.test", 24*time.Hour) + _, hourBody := actionMail("Test", actionPasswordReset, "https://example.test", time.Hour) + if !strings.Contains(dayBody, "1 day") || strings.Contains(dayBody, "1 days") { + t.Fatalf("unexpected day label %q", dayBody) + } + if !strings.Contains(hourBody, "1 hour") || strings.Contains(hourBody, "1 hours") { + t.Fatalf("unexpected hour label %q", hourBody) + } +} diff --git a/internal/controlplane/passkeys.go b/internal/controlplane/passkeys.go new file mode 100644 index 0000000..f9fea56 --- /dev/null +++ b/internal/controlplane/passkeys.go @@ -0,0 +1,294 @@ +package controlplane + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/jackc/pgx/v5" +) + +var ErrNoPasskeys = errors.New("no passkeys enrolled") + +const webAuthnChallengeTTL = 5 * time.Minute + +type consoleWebAuthnUser struct { + actor ConsoleActor + credentials []webauthn.Credential +} + +func (u *consoleWebAuthnUser) WebAuthnID() []byte { return []byte(u.actor.ID) } +func (u *consoleWebAuthnUser) WebAuthnName() string { return u.actor.Email } +func (u *consoleWebAuthnUser) WebAuthnDisplayName() string { return u.actor.DisplayName } +func (u *consoleWebAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } + +func (s *Store) webAuthnUser(ctx context.Context, userID string) (*consoleWebAuthnUser, error) { + var user consoleWebAuthnUser + if err := s.db.QueryRow(ctx, `SELECT id::text,COALESCE(tenant_id::text,''),email,display_name,role + FROM console_users WHERE id=$1 AND status='active' AND email_verified_at IS NOT NULL`, userID). + Scan(&user.actor.ID, &user.actor.TenantID, &user.actor.Email, &user.actor.DisplayName, &user.actor.Role); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrConsoleUnauthorized + } + return nil, err + } + rows, err := s.db.Query(ctx, `SELECT credential_ciphertext FROM console_passkeys WHERE user_id=$1 ORDER BY created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var ciphertext []byte + if err := rows.Scan(&ciphertext); err != nil { + return nil, err + } + plaintext, err := s.cipher.Decrypt(ciphertext) + if err != nil { + return nil, err + } + var credential webauthn.Credential + if err := json.Unmarshal([]byte(plaintext), &credential); err != nil { + return nil, fmt.Errorf("decode stored passkey: %w", err) + } + user.credentials = append(user.credentials, credential) + } + if err := rows.Err(); err != nil { + return nil, err + } + return &user, nil +} + +func (s *Store) WebAuthnUser(ctx context.Context, userID string) (webauthn.User, error) { + return s.webAuthnUser(ctx, userID) +} + +func (s *Store) BeginWebAuthnRegistration(ctx context.Context, userID string, wa *webauthn.WebAuthn) (string, *protocol.CredentialCreation, error) { + user, err := s.webAuthnUser(ctx, userID) + if err != nil { + return "", nil, err + } + creation, session, err := wa.BeginRegistration(user) + if err != nil { + return "", nil, err + } + token, err := s.storeWebAuthnChallenge(ctx, userID, "register", session, "") + if err != nil { + return "", nil, err + } + return token, creation, nil +} + +func (s *Store) BeginWebAuthnLogin(ctx context.Context, userID, purpose, authChallengeID string, wa *webauthn.WebAuthn) (string, *protocol.CredentialAssertion, error) { + user, err := s.webAuthnUser(ctx, userID) + if err != nil { + return "", nil, err + } + if len(user.credentials) == 0 { + return "", nil, ErrNoPasskeys + } + assertion, session, err := wa.BeginLogin(user) + if err != nil { + return "", nil, err + } + token, err := s.storeWebAuthnChallenge(ctx, userID, purpose, session, authChallengeID) + if err != nil { + return "", nil, err + } + return token, assertion, nil +} + +func (s *Store) storeWebAuthnChallenge(ctx context.Context, userID, purpose string, session *webauthn.SessionData, authChallengeID string) (string, error) { + ensureWebAuthnExpiry(session) + encoded, err := json.Marshal(session) + if err != nil { + return "", err + } + ciphertext, err := s.cipher.Encrypt(string(encoded)) + if err != nil { + return "", err + } + raw, hash, err := randomCredential("webauthn-aigw-") + if err != nil { + return "", err + } + _, err = s.db.Exec(ctx, `INSERT INTO console_webauthn_challenges + (user_id,token_hash,purpose,session_ciphertext,auth_challenge_id,expires_at) + VALUES ($1,$2,$3,$4,NULLIF($5,'')::uuid,$6)`, userID, hash, purpose, ciphertext, authChallengeID, session.Expires) + if err != nil { + return "", err + } + return raw, nil +} + +func ensureWebAuthnExpiry(session *webauthn.SessionData) { + if session.Expires.IsZero() { + session.Expires = time.Now().UTC().Add(webAuthnChallengeTTL) + } +} + +func (s *Store) WebAuthnSession(ctx context.Context, rawToken, purpose string) (string, string, webauthn.SessionData, error) { + hash := sha256.Sum256([]byte(strings.TrimSpace(rawToken))) + var userID, authChallengeID string + var ciphertext []byte + err := s.db.QueryRow(ctx, `SELECT user_id::text,COALESCE(auth_challenge_id::text,''),session_ciphertext FROM console_webauthn_challenges + WHERE token_hash=$1 AND purpose=$2 AND consumed_at IS NULL AND expires_at>now()`, hash[:], purpose).Scan(&userID, &authChallengeID, &ciphertext) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", webauthn.SessionData{}, ErrActionTokenInvalid + } + if err != nil { + return "", "", webauthn.SessionData{}, err + } + plaintext, err := s.cipher.Decrypt(ciphertext) + if err != nil { + return "", "", webauthn.SessionData{}, err + } + var session webauthn.SessionData + if err := json.Unmarshal([]byte(plaintext), &session); err != nil { + return "", "", webauthn.SessionData{}, err + } + return userID, authChallengeID, session, nil +} + +func (s *Store) FinishWebAuthnRegistration(ctx context.Context, rawToken, userID, name string, credential *webauthn.Credential) (ConsoleActor, error) { + if credential == nil || len(credential.ID) == 0 { + return ConsoleActor{}, errors.New("passkey credential is empty") + } + encoded, err := json.Marshal(credential) + if err != nil { + return ConsoleActor{}, err + } + ciphertext, err := s.cipher.Encrypt(string(encoded)) + if err != nil { + return ConsoleActor{}, err + } + hash := sha256.Sum256([]byte(strings.TrimSpace(rawToken))) + tx, err := s.db.Begin(ctx) + if err != nil { + return ConsoleActor{}, err + } + defer tx.Rollback(ctx) + var challengeID string + if err := tx.QueryRow(ctx, `SELECT id::text FROM console_webauthn_challenges + WHERE token_hash=$1 AND user_id=$2 AND purpose='register' AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, hash[:], userID).Scan(&challengeID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ConsoleActor{}, ErrActionTokenInvalid + } + return ConsoleActor{}, err + } + label := strings.TrimSpace(name) + if label == "" { + label = "Passkey" + } + if _, err := tx.Exec(ctx, `INSERT INTO console_passkeys (user_id,credential_id,credential_ciphertext,name) + VALUES ($1,$2,$3,$4)`, userID, credential.ID, ciphertext, label); err != nil { + return ConsoleActor{}, err + } + if _, err := tx.Exec(ctx, `UPDATE console_webauthn_challenges SET consumed_at=now() WHERE id=$1`, challengeID); err != nil { + return ConsoleActor{}, err + } + actor, err := actorInTx(ctx, tx, userID) + if err != nil { + return ConsoleActor{}, err + } + if err := tx.Commit(ctx); err != nil { + return ConsoleActor{}, err + } + return actor, nil +} + +func (s *Store) FinishWebAuthnLogin(ctx context.Context, rawToken, userID, authChallengeID string, credential *webauthn.Credential, method string) (ConsoleActor, error) { + if credential == nil || len(credential.ID) == 0 { + return ConsoleActor{}, errors.New("passkey assertion is empty") + } + encoded, err := json.Marshal(credential) + if err != nil { + return ConsoleActor{}, err + } + ciphertext, err := s.cipher.Encrypt(string(encoded)) + if err != nil { + return ConsoleActor{}, err + } + hash := sha256.Sum256([]byte(strings.TrimSpace(rawToken))) + tx, err := s.db.Begin(ctx) + if err != nil { + return ConsoleActor{}, err + } + defer tx.Rollback(ctx) + var challengeID string + if err := tx.QueryRow(ctx, `SELECT id::text FROM console_webauthn_challenges + WHERE token_hash=$1 AND user_id=$2 AND purpose=$3 AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, hash[:], userID, method).Scan(&challengeID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ConsoleActor{}, ErrActionTokenInvalid + } + return ConsoleActor{}, err + } + result, err := tx.Exec(ctx, `UPDATE console_passkeys SET credential_ciphertext=$3,last_used_at=now() + WHERE user_id=$1 AND credential_id=$2`, userID, credential.ID, ciphertext) + if err != nil { + return ConsoleActor{}, err + } + if result.RowsAffected() == 0 { + return ConsoleActor{}, errors.New("passkey is not enrolled") + } + if _, err := tx.Exec(ctx, `UPDATE console_webauthn_challenges SET consumed_at=now() WHERE id=$1`, challengeID); err != nil { + return ConsoleActor{}, err + } + if authChallengeID != "" { + result, err := tx.Exec(ctx, `UPDATE console_auth_challenges SET consumed_at=now() WHERE id=$1 AND consumed_at IS NULL AND expires_at>now()`, authChallengeID) + if err != nil { + return ConsoleActor{}, err + } + if result.RowsAffected() != 1 { + return ConsoleActor{}, ErrActionTokenInvalid + } + } + actor, err := actorInTx(ctx, tx, userID) + if err != nil { + return ConsoleActor{}, err + } + if err := tx.Commit(ctx); err != nil { + return ConsoleActor{}, err + } + return actor, nil +} + +func actorInTx(ctx context.Context, tx pgx.Tx, userID string) (ConsoleActor, error) { + var actor ConsoleActor + err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(tenant_id::text,''),email,display_name,role + FROM console_users WHERE id=$1 AND status='active'`, userID).Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role) + return actor, err +} + +func (s *Store) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) { + rows, err := s.db.Query(ctx, `SELECT id::text,name,created_at,last_used_at FROM console_passkeys WHERE user_id=$1 ORDER BY created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]Passkey, 0) + for rows.Next() { + var item Passkey + if err := rows.Scan(&item.ID, &item.Name, &item.CreatedAt, &item.LastUsedAt); err != nil { + return nil, err + } + result = append(result, item) + } + return result, rows.Err() +} + +func (s *Store) DeletePasskey(ctx context.Context, userID, id string) error { + result, err := s.db.Exec(ctx, `DELETE FROM console_passkeys WHERE id=$1 AND user_id=$2`, id, userID) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/controlplane/passkeys_test.go b/internal/controlplane/passkeys_test.go new file mode 100644 index 0000000..8162133 --- /dev/null +++ b/internal/controlplane/passkeys_test.go @@ -0,0 +1,27 @@ +package controlplane + +import ( + "testing" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +func TestEnsureWebAuthnExpirySetsServerSideDeadline(t *testing.T) { + before := time.Now().UTC() + session := &webauthn.SessionData{} + ensureWebAuthnExpiry(session) + if session.Expires.Before(before.Add(webAuthnChallengeTTL-time.Second)) || + session.Expires.After(time.Now().UTC().Add(webAuthnChallengeTTL+time.Second)) { + t.Fatalf("expiry %s is not approximately %s from now", session.Expires, webAuthnChallengeTTL) + } +} + +func TestEnsureWebAuthnExpiryPreservesLibraryDeadline(t *testing.T) { + want := time.Now().UTC().Add(time.Minute) + session := &webauthn.SessionData{Expires: want} + ensureWebAuthnExpiry(session) + if !session.Expires.Equal(want) { + t.Fatalf("expiry = %s, want %s", session.Expires, want) + } +} diff --git a/internal/controlplane/schema.sql b/internal/controlplane/schema.sql index fc7f5e7..8a5a605 100644 --- a/internal/controlplane/schema.sql +++ b/internal/controlplane/schema.sql @@ -214,10 +214,20 @@ ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_hash BYTEA; ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_salt BYTEA; ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_iterations INTEGER; ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ; +ALTER TABLE console_users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ; +ALTER TABLE console_users ADD COLUMN IF NOT EXISTS invited_at TIMESTAMPTZ; +ALTER TABLE console_users ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ; +ALTER TABLE console_users DROP CONSTRAINT IF EXISTS console_users_status_check; +ALTER TABLE console_users ADD CONSTRAINT console_users_status_check + CHECK (status IN ('pending_verification', 'invited', 'active', 'revoked')); +UPDATE console_users SET email_verified_at = COALESCE(email_verified_at, created_at) + WHERE status = 'active' AND password_hash IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS console_users_email_tenant_idx ON console_users (lower(email), COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid)); CREATE UNIQUE INDEX IF NOT EXISTS console_users_login_email_idx ON console_users (lower(email)) WHERE password_hash IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS console_users_global_email_idx + ON console_users (lower(email)); CREATE TABLE IF NOT EXISTS console_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -229,8 +239,14 @@ CREATE TABLE IF NOT EXISTS console_sessions ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ, remote_ip INET, - user_agent TEXT NOT NULL DEFAULT '' + user_agent TEXT NOT NULL DEFAULT '', + auth_method TEXT NOT NULL DEFAULT 'password', + mfa_verified_at TIMESTAMPTZ ); +ALTER TABLE console_sessions ADD COLUMN IF NOT EXISTS auth_method TEXT NOT NULL DEFAULT 'password'; +ALTER TABLE console_sessions ADD COLUMN IF NOT EXISTS mfa_verified_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS console_sessions_user_active_idx + ON console_sessions (user_id, created_at DESC) WHERE revoked_at IS NULL; CREATE TABLE IF NOT EXISTS console_login_throttles ( identity_hash BYTEA PRIMARY KEY CHECK (octet_length(identity_hash) = 32), @@ -240,6 +256,101 @@ CREATE TABLE IF NOT EXISTS console_login_throttles ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +CREATE TABLE IF NOT EXISTS console_rate_limits ( + bucket_hash BYTEA PRIMARY KEY CHECK (octet_length(bucket_hash) = 32), + hits INTEGER NOT NULL DEFAULT 0, + window_started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS console_action_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE, + purpose TEXT NOT NULL CHECK (purpose IN ('verify_email', 'password_reset', 'invite')), + token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + requested_ip INET +); +CREATE INDEX IF NOT EXISTS console_action_tokens_active_idx + ON console_action_tokens (user_id, purpose, expires_at DESC) WHERE consumed_at IS NULL; + +CREATE TABLE IF NOT EXISTS console_mail_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + recipient TEXT NOT NULL, + template TEXT NOT NULL CHECK (template IN ('verify_email', 'password_reset', 'invite')), + subject TEXT NOT NULL, + body_ciphertext BYTEA NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sending', 'sent', 'failed')), + attempts INTEGER NOT NULL DEFAULT 0, + available_at TIMESTAMPTZ NOT NULL DEFAULT now(), + claimed_at TIMESTAMPTZ, + sent_at TIMESTAMPTZ, + last_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS console_mail_outbox_pending_idx + ON console_mail_outbox (available_at, created_at) WHERE status IN ('pending', 'failed'); + +CREATE TABLE IF NOT EXISTS console_auth_challenges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE, + token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), + purpose TEXT NOT NULL CHECK (purpose IN ('mfa_login')), + allowed_methods JSONB NOT NULL DEFAULT '[]'::jsonb, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + attempts INTEGER NOT NULL DEFAULT 0, + remote_ip INET, + user_agent TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS console_auth_challenges_active_idx + ON console_auth_challenges (user_id, expires_at DESC) WHERE consumed_at IS NULL; + +CREATE TABLE IF NOT EXISTS console_totp_credentials ( + user_id UUID PRIMARY KEY REFERENCES console_users(id) ON DELETE CASCADE, + secret_ciphertext BYTEA NOT NULL, + confirmed_at TIMESTAMPTZ, + last_used_step BIGINT NOT NULL DEFAULT -1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS console_recovery_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE, + code_hash BYTEA NOT NULL CHECK (octet_length(code_hash) = 32), + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, code_hash) +); + +CREATE TABLE IF NOT EXISTS console_passkeys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE, + credential_id BYTEA NOT NULL UNIQUE, + credential_ciphertext BYTEA NOT NULL, + name TEXT NOT NULL DEFAULT 'Passkey', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS console_passkeys_user_idx ON console_passkeys (user_id, created_at); + +CREATE TABLE IF NOT EXISTS console_webauthn_challenges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE, + token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), + purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login', 'mfa_login')), + session_ciphertext BYTEA NOT NULL, + auth_challenge_id UUID REFERENCES console_auth_challenges(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS console_webauthn_challenges_active_idx + ON console_webauthn_challenges (user_id, expires_at DESC) WHERE consumed_at IS NULL; + CREATE TABLE IF NOT EXISTS project_limits ( project_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, diff --git a/internal/controlplane/types.go b/internal/controlplane/types.go index 959a9ee..f81e63b 100644 --- a/internal/controlplane/types.go +++ b/internal/controlplane/types.go @@ -150,25 +150,28 @@ type CreateModelInput struct { } type ConsoleActor struct { - ID string `json:"id,omitempty"` - TenantID string `json:"tenant_id,omitempty"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - Bootstrap bool `json:"bootstrap"` + ID string `json:"id,omitempty"` + TenantID string `json:"tenant_id,omitempty"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Bootstrap bool `json:"bootstrap"` + MFAMethods []string `json:"mfa_methods,omitempty"` } type ConsoleUser struct { - ID string `json:"id"` - TenantID string `json:"tenant_id,omitempty"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - TokenPrefix string `json:"token_prefix,omitempty"` - HasPassword bool `json:"has_password"` - Status string `json:"status"` - LastUsedAt *time.Time `json:"last_used_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TenantID string `json:"tenant_id,omitempty"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + TokenPrefix string `json:"token_prefix,omitempty"` + HasPassword bool `json:"has_password"` + Status string `json:"status"` + EmailVerified bool `json:"email_verified"` + MFAMethods []string `json:"mfa_methods"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` } type CreatedConsoleUser struct { @@ -180,7 +183,7 @@ type CreateConsoleUserInput struct { Email string `json:"email"` DisplayName string `json:"display_name"` Role string `json:"role"` - Password string `json:"password"` + Password string `json:"-"` } type RegisterInput struct { @@ -201,11 +204,70 @@ type PasswordChangeInput struct { NewPassword string `json:"new_password"` } +type PasswordResetInput struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` +} + +type InviteAcceptInput struct { + Token string `json:"token"` + DisplayName string `json:"display_name"` + Password string `json:"password"` +} + +type MFACodeInput struct { + ChallengeToken string `json:"challenge_token"` + Code string `json:"code"` +} + type ConsoleSession struct { - Actor ConsoleActor - Token string - CSRFToken string - ExpiresAt time.Time + Actor ConsoleActor + Token string + CSRFToken string + ExpiresAt time.Time + ID string + AuthMethod string +} + +type DeviceSession struct { + ID string `json:"id"` + RemoteIP string `json:"remote_ip,omitempty"` + UserAgent string `json:"user_agent"` + AuthMethod string `json:"auth_method"` + MFAVerifiedAt *time.Time `json:"mfa_verified_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt time.Time `json:"last_seen_at"` + ExpiresAt time.Time `json:"expires_at"` + Current bool `json:"current"` +} + +type AuthChallenge struct { + Token string `json:"challenge_token"` + Methods []string `json:"methods"` + ExpiresAt time.Time `json:"expires_at"` +} + +type TOTPEnrollment struct { + Secret string `json:"secret"` + URI string `json:"uri"` + QRCode string `json:"qr_code"` +} + +type MFAStatus struct { + TOTPEnabled bool `json:"totp_enabled"` + Passkeys []Passkey `json:"passkeys"` +} + +type Passkey struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} + +type ActionResult struct { + Actor ConsoleActor + Generation int64 } type ProjectLimit struct { diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..4ec11ac --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,170 @@ +package mailer + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log/slog" + "net" + "net/smtp" + "strings" + "time" +) + +type Message struct { + ID string + Recipient string + Subject string + Body string +} + +type Queue interface { + ClaimMail(context.Context) (Message, bool, error) + MarkMailSent(context.Context, string) error + MarkMailFailed(context.Context, string, error) error +} + +type SMTPConfig struct { + Address string + Username string + Password string + FromName string + FromAddress string + TLSMode string + DialTimeout time.Duration +} + +type Sender struct { + config SMTPConfig +} + +func NewSender(config SMTPConfig) (*Sender, error) { + if strings.TrimSpace(config.Address) == "" || strings.TrimSpace(config.FromAddress) == "" { + return nil, errors.New("SMTP address and from address are required") + } + if config.DialTimeout <= 0 { + config.DialTimeout = 10 * time.Second + } + return &Sender{config: config}, nil +} + +func (s *Sender) Send(ctx context.Context, message Message) error { + host, _, err := net.SplitHostPort(s.config.Address) + if err != nil { + return fmt.Errorf("parse SMTP address: %w", err) + } + dialer := net.Dialer{Timeout: s.config.DialTimeout} + var conn net.Conn + if s.config.TLSMode == "tls" { + conn, err = tls.DialWithDialer(&dialer, "tcp", s.config.Address, &tls.Config{MinVersion: tls.VersionTLS12, ServerName: host}) + } else { + conn, err = dialer.DialContext(ctx, "tcp", s.config.Address) + } + if err != nil { + return fmt.Errorf("connect SMTP: %w", err) + } + defer conn.Close() + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("open SMTP client: %w", err) + } + defer client.Close() + if s.config.TLSMode == "starttls" { + if ok, _ := client.Extension("STARTTLS"); !ok { + return errors.New("SMTP server does not support required STARTTLS") + } + if err := client.StartTLS(&tls.Config{MinVersion: tls.VersionTLS12, ServerName: host}); err != nil { + return fmt.Errorf("start SMTP TLS: %w", err) + } + } + if s.config.Username != "" { + if ok, _ := client.Extension("AUTH"); !ok { + return errors.New("SMTP server does not support authentication") + } + if err := client.Auth(smtp.PlainAuth("", s.config.Username, s.config.Password, host)); err != nil { + return fmt.Errorf("authenticate SMTP: %w", err) + } + } + if err := client.Mail(s.config.FromAddress); err != nil { + return fmt.Errorf("set SMTP sender: %w", err) + } + if err := client.Rcpt(message.Recipient); err != nil { + return fmt.Errorf("set SMTP recipient: %w", err) + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("start SMTP body: %w", err) + } + from := s.config.FromAddress + if name := cleanHeader(s.config.FromName); name != "" { + from = fmt.Sprintf("%s <%s>", name, s.config.FromAddress) + } + body := strings.ReplaceAll(message.Body, "\r\n", "\n") + body = strings.ReplaceAll(body, "\n", "\r\n") + _, writeErr := fmt.Fprintf(w, "From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n%s\r\n", + from, cleanHeader(message.Recipient), cleanHeader(message.Subject), body) + closeErr := w.Close() + if writeErr != nil { + return fmt.Errorf("write SMTP body: %w", writeErr) + } + if closeErr != nil { + return fmt.Errorf("finish SMTP body: %w", closeErr) + } + if err := client.Quit(); err != nil { + return fmt.Errorf("finish SMTP session: %w", err) + } + return nil +} + +func cleanHeader(value string) string { + value = strings.ReplaceAll(value, "\r", "") + return strings.ReplaceAll(value, "\n", "") +} + +type Worker struct { + queue Queue + sender *Sender + logger *slog.Logger +} + +func NewWorker(queue Queue, sender *Sender, logger *slog.Logger) *Worker { + if logger == nil { + logger = slog.Default() + } + return &Worker{queue: queue, sender: sender, logger: logger} +} + +func (w *Worker) Run(ctx context.Context) { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for { + w.drain(ctx) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (w *Worker) drain(ctx context.Context) { + for i := 0; i < 20 && ctx.Err() == nil; i++ { + message, ok, err := w.queue.ClaimMail(ctx) + if err != nil { + w.logger.Warn("mail_outbox_claim_failed", "error", err) + return + } + if !ok { + return + } + if err := w.sender.Send(ctx, message); err != nil { + _ = w.queue.MarkMailFailed(ctx, message.ID, err) + w.logger.Warn("mail_delivery_failed", "message_id", message.ID, "error", err) + continue + } + if err := w.queue.MarkMailSent(ctx, message.ID); err != nil { + w.logger.Warn("mail_outbox_complete_failed", "message_id", message.ID, "error", err) + } + } +} |
