// SPDX-License-Identifier: MPL-2.0 package authsqlite import ( "context" "database/sql" "encoding/base64" "errors" "time" "gamertan.com/web/access" "gamertan.com/web/auth" "gamertan.com/web/authrecovery" ) func (store *Store) IssueAssistedRecovery(ctx context.Context, grant authrecovery.AssistedGrant, ownerRole string, authAudit auth.AuditEvent, accessAudit access.AuditEvent) (auth.User, error) { if !validAssistedGrant(grant) || !safeName(ownerRole) || !validAuditEvent(authAudit) || authAudit.ActorUserID != grant.IssuedByUserID || authAudit.Action != "auth.assisted-recovery.issue" || authAudit.ResourceType != "user" || authAudit.ResourceID != grant.UserID || !authAudit.CreatedAt.Equal(grant.CreatedAt) || !validAccessAudit(accessAudit) || accessAudit.OrganizationID != grant.OrganizationID || accessAudit.ActorUserID != grant.IssuedByUserID || accessAudit.Action != "access.account-recovery.issue" || accessAudit.ResourceType != "user" || accessAudit.ResourceID != grant.UserID || !accessAudit.CreatedAt.Equal(grant.CreatedAt) { return auth.User{}, errors.New("authsqlite: invalid assisted recovery issue") } tx, err := store.db.BeginTx(ctx, nil) if err != nil { return auth.User{}, err } defer tx.Rollback() // Take the SQLite write lock before checking owner authority so a role or // membership mutation cannot race the reviewed recovery decision. result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status WHERE organization_id=? AND user_id=? AND status='active' AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active') AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0) AND EXISTS (SELECT 1 FROM gwf_access_bindings b WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id=? AND b.role_name=? AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL AND b.revoked_at IS NULL)`, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, ownerRole) if err != nil { return auth.User{}, err } if changed, _ := result.RowsAffected(); changed != 1 { return auth.User{}, authrecovery.ErrAssistedDenied } user, err := scanPasskeyUser(tx.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_users u JOIN gwf_organization_memberships m ON m.user_id=u.id WHERE u.id=? AND u.status='active' AND u.registration_pending=0 AND m.organization_id=? AND m.status='active'`, grant.UserID, grant.OrganizationID)) if errors.Is(err, auth.ErrUserNotFound) { return auth.User{}, authrecovery.ErrAssistedDenied } if err != nil { return auth.User{}, err } for _, statement := range []string{ `DELETE FROM gwf_auth_sessions WHERE user_id=?`, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, `DELETE FROM gwf_recovery_grants WHERE user_id=?`, `DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`, `DELETE FROM gwf_password_credentials WHERE user_id=?`, `DELETE FROM gwf_passkey_credentials WHERE user_id=?`, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, } { if _, err = tx.ExecContext(ctx, statement, grant.UserID); err != nil { return auth.User{}, err } } if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_assisted_recovery_grants(token_hash,user_id,organization_id,issued_by_user_id,created_at,expires_at) VALUES(?,?,?,?,?,?)`, grant.Digest[:], grant.UserID, grant.OrganizationID, grant.IssuedByUserID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil { return auth.User{}, err } if err = appendAudit(ctx, tx, authAudit); err != nil { return auth.User{}, err } if err = appendAccessAudit(ctx, tx, accessAudit); err != nil { return auth.User{}, err } if err = tx.Commit(); err != nil { return auth.User{}, err } return user, nil } func (store *Store) AssistedRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (authrecovery.AssistedGrant, auth.User, error) { if zeroDigest(digest) || now.IsZero() { return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound } var grant authrecovery.AssistedGrant var user auth.User var created, expires, userCreated, userUpdated int64 var passwordChangeRequired, registrationPending int err := store.db.QueryRowContext(ctx, `SELECT g.user_id,g.organization_id,g.issued_by_user_id,g.created_at,g.expires_at,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at FROM gwf_assisted_recovery_grants g JOIN gwf_users u ON u.id=g.user_id AND u.status='active' AND u.registration_pending=0 JOIN gwf_organizations o ON o.id=g.organization_id AND o.status='active' JOIN gwf_organization_memberships m ON m.organization_id=g.organization_id AND m.user_id=g.user_id AND m.status='active' WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()).Scan(&grant.UserID, &grant.OrganizationID, &grant.IssuedByUserID, &created, &expires, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &userCreated, &userUpdated) if errors.Is(err, sql.ErrNoRows) { return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound } if err != nil { return authrecovery.AssistedGrant{}, auth.User{}, err } grant.Digest, grant.CreatedAt, grant.ExpiresAt = digest, time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC() user.ID, user.PasswordChangeRequired, user.RegistrationPending = grant.UserID, passwordChangeRequired == 1, registrationPending == 1 user.CreatedAt, user.UpdatedAt = time.Unix(userCreated, 0).UTC(), time.Unix(userUpdated, 0).UTC() return grant, user, nil } func (store *Store) CompleteAssistedRecovery(ctx context.Context, completion authrecovery.AssistedCompletion) error { credential := completion.Credential credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID) if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || !text(completion.PasswordHash, 1024, false) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || !validAuditEvent(completion.RecoveryAudit) || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.assisted-recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID || !completion.RecoveryAudit.CreatedAt.Equal(completion.CompletedAt) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.ActorUserID != credential.UserID || completion.AccessAudit.Action != "access.account-recovery.complete" || completion.AccessAudit.ResourceType != "user" || completion.AccessAudit.ResourceID != credential.UserID || !completion.AccessAudit.CreatedAt.Equal(completion.CompletedAt) { return errors.New("authsqlite: invalid assisted recovery completion") } seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests)) for _, digest := range completion.RecoveryDigests { if zeroDigest(digest) { return errors.New("authsqlite: invalid assisted recovery-code digest") } if _, duplicate := seen[digest]; duplicate { return errors.New("authsqlite: duplicate assisted recovery-code digest") } seen[digest] = struct{}{} } tx, err := store.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var userID, organizationID string err = tx.QueryRowContext(ctx, `DELETE FROM gwf_assisted_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id,organization_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID, &organizationID) if errors.Is(err, sql.ErrNoRows) { return authrecovery.ErrAssistedNotFound } if err != nil { return err } if userID != credential.UserID || organizationID != completion.AccessAudit.OrganizationID { return errors.New("authsqlite: assisted recovery identity mismatch") } var active int if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users u JOIN gwf_organization_memberships m ON m.user_id=u.id AND m.organization_id=? AND m.status='active' JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active' WHERE u.id=? AND u.status='active' AND u.registration_pending=0`, organizationID, userID).Scan(&active); err != nil { return err } if active != 1 { return auth.ErrInactiveUser } for _, statement := range []string{ `DELETE FROM gwf_auth_sessions WHERE user_id=?`, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, `DELETE FROM gwf_recovery_grants WHERE user_id=?`, `DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`, `DELETE FROM gwf_password_credentials WHERE user_id=?`, `DELETE FROM gwf_passkey_credentials WHERE user_id=?`, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, } { if _, err = tx.ExecContext(ctx, statement, userID); err != nil { return err } } if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, userID, completion.PasswordHash, completion.CompletedAt.Unix()); 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 } 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, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, completion.CompletedAt.Unix(), 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 } if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil { return err } return tx.Commit() } func validAssistedGrant(grant authrecovery.AssistedGrant) bool { return !zeroDigest(grant.Digest) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && opaqueID(grant.IssuedByUserID) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) >= 5*time.Minute && grant.ExpiresAt.Sub(grant.CreatedAt) <= 30*time.Minute }