summaryrefslogtreecommitdiff
path: root/internal/controlplane/access.go
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/access.go
parent86b1f42e3c5601ff10621a9779cf0076590797a1 (diff)
add passkey, totp.
Diffstat (limited to '')
-rw-r--r--internal/controlplane/access.go114
1 files changed, 94 insertions, 20 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