summaryrefslogtreecommitdiff
path: root/internal/controlplane/passkeys.go
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--internal/controlplane/passkeys.go294
1 files changed, 294 insertions, 0 deletions
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
+}