From cd0dd91ab93653631904f2ea0e574ccde6d60339 Mon Sep 17 00:00:00 2001 From: Chia Date: Wed, 5 Aug 2026 14:48:00 +1200 Subject: add passkey, totp. --- internal/controlplane/passkeys.go | 294 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 internal/controlplane/passkeys.go (limited to 'internal/controlplane/passkeys.go') 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 +} -- cgit v1.2.3