diff options
Diffstat (limited to 'internal/controlplane')
| -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 |
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 { |
