summaryrefslogtreecommitdiff
path: root/internal/controlplane
diff options
context:
space:
mode:
authorChia <Chia@93.nz>2026-08-05 14:48:00 +1200
committerChia <Chia@93.nz>2026-08-05 14:48:00 +1200
commitcd0dd91ab93653631904f2ea0e574ccde6d60339 (patch)
treec65417b880a3f4a35c504c44edae821bc2122f70 /internal/controlplane
parent86b1f42e3c5601ff10621a9779cf0076590797a1 (diff)
add passkey, totp.
Diffstat (limited to 'internal/controlplane')
-rw-r--r--internal/controlplane/access.go114
-rw-r--r--internal/controlplane/identity.go291
-rw-r--r--internal/controlplane/mfa.go344
-rw-r--r--internal/controlplane/outbox.go152
-rw-r--r--internal/controlplane/outbox_test.go33
-rw-r--r--internal/controlplane/passkeys.go294
-rw-r--r--internal/controlplane/passkeys_test.go27
-rw-r--r--internal/controlplane/schema.sql113
-rw-r--r--internal/controlplane/types.go104
9 files changed, 1430 insertions, 42 deletions
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 {