Files
web/authsqlite/assisted_recovery.go
T
gamertan 6f0b597943
verify / verify (push) Successful in 3m39s
Add owner-assisted account recovery
2026-09-03 23:56:42 -04:00

191 lines
11 KiB
Go

// 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, &registrationPending, &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
}