196 lines
8.0 KiB
Go
196 lines
8.0 KiB
Go
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package authsqlite
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"time"
|
|
|
|
"gamertan.com/web/auth"
|
|
"gamertan.com/web/authrecovery"
|
|
)
|
|
|
|
func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, digests [][32]byte, createdAt time.Time, audit auth.AuditEvent) error {
|
|
if !opaqueID(userID) || len(digests) < 5 || len(digests) > 20 || createdAt.IsZero() || !validAuditEvent(audit) || audit.ResourceID != userID {
|
|
return errors.New("authsqlite: invalid recovery-code set")
|
|
}
|
|
tx, err := store.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil {
|
|
return err
|
|
}
|
|
for _, digest := range digests {
|
|
if zeroDigest(digest) {
|
|
return errors.New("authsqlite: invalid recovery-code digest")
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES(?,?,?)`, userID, digest[:], createdAt.Unix()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (store *Store) RecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
|
|
if zeroDigest(digest) || now.IsZero() {
|
|
return auth.User{}, authrecovery.ErrGrantNotFound
|
|
}
|
|
user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at FROM gwf_recovery_grants g JOIN gwf_users u ON u.id=g.user_id WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()))
|
|
if errors.Is(err, auth.ErrUserNotFound) {
|
|
return auth.User{}, authrecovery.ErrGrantNotFound
|
|
}
|
|
return user, err
|
|
}
|
|
|
|
func (store *Store) CompletePasskeyRecovery(ctx context.Context, completion authrecovery.PasskeyCompletion) error {
|
|
credential := completion.Credential
|
|
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
|
|
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || !validAuditEvent(completion.RecoveryAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID {
|
|
return errors.New("authsqlite: invalid passkey recovery completion")
|
|
}
|
|
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
|
|
for _, digest := range completion.RecoveryDigests {
|
|
if zeroDigest(digest) {
|
|
return errors.New("authsqlite: invalid recovery-code digest")
|
|
}
|
|
if _, exists := seen[digest]; exists {
|
|
return errors.New("authsqlite: duplicate recovery-code digest")
|
|
}
|
|
seen[digest] = struct{}{}
|
|
}
|
|
tx, err := store.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
var userID string
|
|
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return authrecovery.ErrGrantNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if userID != credential.UserID {
|
|
return errors.New("authsqlite: passkey recovery identity mismatch")
|
|
}
|
|
var active, pending int
|
|
if err = tx.QueryRowContext(ctx, `SELECT status='active',registration_pending FROM gwf_users WHERE id=?`, userID).Scan(&active, &pending); err != nil || active != 1 || pending != 0 {
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
return auth.ErrInactiveUser
|
|
}
|
|
existing, err := tx.QueryContext(ctx, `SELECT credential_id FROM gwf_passkey_credentials WHERE user_id=?`, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for existing.Next() {
|
|
var id []byte
|
|
if err = existing.Scan(&id); err != nil {
|
|
existing.Close()
|
|
return err
|
|
}
|
|
if bytes.Equal(id, credential.ID) {
|
|
existing.Close()
|
|
return errors.New("authsqlite: passkey credential already exists")
|
|
}
|
|
}
|
|
if err = existing.Close(); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil {
|
|
return err
|
|
}
|
|
for _, digest := range completion.RecoveryDigests {
|
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, userID); err != nil {
|
|
return err
|
|
}
|
|
if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil {
|
|
return err
|
|
}
|
|
if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (store *Store) ConsumeRecoveryCodeAndCreateGrant(ctx context.Context, userID string, codeDigest [32]byte, grant authrecovery.Grant, audit auth.AuditEvent) error {
|
|
if !opaqueID(userID) || zeroDigest(codeDigest) || grant.UserID != userID || zeroDigest(grant.Digest) || grant.CreatedAt.IsZero() || !grant.ExpiresAt.After(grant.CreatedAt) || grant.ExpiresAt.Sub(grant.CreatedAt) > 30*time.Minute || !validAuditEvent(audit) || audit.ResourceID != userID {
|
|
return errors.New("authsqlite: invalid recovery attempt")
|
|
}
|
|
tx, err := store.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_recovery_codes SET used_at=? WHERE user_id=? AND code_hash=? AND used_at IS NULL`, grant.CreatedAt.Unix(), userID, codeDigest[:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changed, err := result.RowsAffected()
|
|
if err != nil || changed != 1 {
|
|
return authrecovery.ErrCodeNotFound
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_grants WHERE user_id=?`, userID); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, grant.Digest[:], userID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
|
|
return err
|
|
}
|
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (store *Store) TakeRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
|
|
if zeroDigest(digest) || now.IsZero() {
|
|
return auth.User{}, authrecovery.ErrGrantNotFound
|
|
}
|
|
tx, err := store.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return auth.User{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
var userID string
|
|
if err = tx.QueryRowContext(ctx, `SELECT user_id FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>?`, digest[:], now.Unix()).Scan(&userID); errors.Is(err, sql.ErrNoRows) {
|
|
return auth.User{}, authrecovery.ErrGrantNotFound
|
|
} else if err != nil {
|
|
return auth.User{}, err
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=?`, digest[:]); err != nil {
|
|
return auth.User{}, err
|
|
}
|
|
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
|
|
if err != nil {
|
|
return auth.User{}, err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return auth.User{}, err
|
|
}
|
|
return user, nil
|
|
}
|