191 lines
11 KiB
Go
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, ®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
|
|
}
|