verify / verify (push) Successful in 4m29s
Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
416 lines
19 KiB
Go
416 lines
19 KiB
Go
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package authsqlite
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"gamertan.com/web/auth"
|
|
"gamertan.com/web/authmail"
|
|
"gamertan.com/web/mail"
|
|
"gamertan.com/web/mailsqlite"
|
|
"modernc.org/sqlite"
|
|
)
|
|
|
|
// MailRepository is optional. Construction opens no new connection and performs
|
|
// no migration. The outbox shares this store's DB so account/audit/mail commits
|
|
// are atomic. Applications must check RequireMailSchema before enabling routes.
|
|
type MailRepository struct {
|
|
store *Store
|
|
queue *mailsqlite.Queue
|
|
now func() time.Time
|
|
}
|
|
|
|
func (store *Store) AccountMail(options mailsqlite.Options) (*MailRepository, *mailsqlite.Queue, error) {
|
|
if options.Now == nil {
|
|
options.Now = time.Now
|
|
}
|
|
queue, err := mailsqlite.New(store.db, options)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &MailRepository{store: store, queue: queue, now: options.Now}, queue, nil
|
|
}
|
|
|
|
const MailSchemaVersion = 1
|
|
|
|
func (store *Store) RequireMailSchema(ctx context.Context) error {
|
|
var version int
|
|
if err := store.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM gwf_account_mail_migrations`).Scan(&version); err != nil {
|
|
return errors.New("authsqlite: explicit account mail migration required")
|
|
}
|
|
if version != MailSchemaVersion {
|
|
return errors.New("authsqlite: incompatible account mail schema")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MigrateMail is an explicit operator migration, separate from schema 11. It
|
|
// creates no verified identities for existing users and touches no commerce data.
|
|
func (store *Store) MigrateMail(ctx context.Context) error {
|
|
if err := store.RequireCurrentSchema(ctx); err != nil {
|
|
return err
|
|
}
|
|
tx, err := store.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err = tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS gwf_account_mail_migrations(version INTEGER PRIMARY KEY,applied_at INTEGER NOT NULL)`); err != nil {
|
|
return err
|
|
}
|
|
var version int
|
|
if err = tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM gwf_account_mail_migrations`).Scan(&version); err != nil {
|
|
return err
|
|
}
|
|
if version > MailSchemaVersion {
|
|
return errors.New("authsqlite: newer account mail schema")
|
|
}
|
|
if version == MailSchemaVersion {
|
|
return tx.Commit()
|
|
}
|
|
if err = mailsqlite.CreateSchema(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
for _, statement := range []string{
|
|
`CREATE TABLE gwf_verified_emails(user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE,email_normalized TEXT NOT NULL,verified_at INTEGER NOT NULL)`,
|
|
`CREATE TABLE gwf_account_mail_requests(id TEXT NOT NULL UNIQUE,user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE,purpose TEXT NOT NULL CHECK(purpose IN ('verify','change','reset')),email TEXT NOT NULL,new_email TEXT NOT NULL,profile_revision INTEGER NOT NULL,credential_digest BLOB NOT NULL CHECK(length(credential_digest)=32),session_digest BLOB NOT NULL CHECK(length(session_digest)=32),new_digest BLOB NOT NULL UNIQUE CHECK(length(new_digest)=32),old_digest BLOB UNIQUE,new_confirmed INTEGER NOT NULL DEFAULT 0 CHECK(new_confirmed IN (0,1)),old_confirmed INTEGER NOT NULL DEFAULT 0 CHECK(old_confirmed IN (0,1)),created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL CHECK(expires_at>created_at),PRIMARY KEY(user_id,purpose),CHECK(old_digest IS NULL OR length(old_digest)=32))`,
|
|
`CREATE INDEX gwf_account_mail_requests_expiry ON gwf_account_mail_requests(expires_at)`,
|
|
`CREATE TABLE gwf_account_mail_limits(user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE,window_started INTEGER NOT NULL,request_count INTEGER NOT NULL,last_requested INTEGER NOT NULL)`,
|
|
} {
|
|
if _, err = tx.ExecContext(ctx, statement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_migrations(version,applied_at) VALUES(?,?)`, MailSchemaVersion, time.Now().UTC().Unix()); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
const mailSubjectSelect = `SELECT u.id,u.email,c.password_hash,u.profile_revision,EXISTS(SELECT 1 FROM gwf_verified_emails v WHERE v.user_id=u.id AND v.email_normalized=u.email_normalized)
|
|
FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.status='active' AND u.registration_pending=0`
|
|
|
|
func scanMailSubject(row interface{ Scan(...any) error }) (authmail.Subject, error) {
|
|
var subject authmail.Subject
|
|
err := row.Scan(&subject.UserID, &subject.Email, &subject.PasswordHash, &subject.Revision, &subject.Verified)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return subject, authmail.ErrUnavailable
|
|
}
|
|
return subject, err
|
|
}
|
|
|
|
func (repo *MailRepository) OwnSubject(ctx context.Context, session [32]byte, now time.Time) (authmail.Subject, error) {
|
|
if zeroDigest(session) || now.IsZero() {
|
|
return authmail.Subject{}, authmail.ErrUnavailable
|
|
}
|
|
return scanMailSubject(repo.store.db.QueryRowContext(ctx, mailSubjectSelect+` AND u.password_change_required=0 AND EXISTS(SELECT 1 FROM gwf_auth_sessions s WHERE s.user_id=u.id AND s.token_hash=? AND s.expires_at>?)`, session[:], now.Unix()))
|
|
}
|
|
|
|
func (repo *MailRepository) ResetSubject(ctx context.Context, email string, now time.Time) (authmail.Subject, error) {
|
|
value, err := authmail.NormalizeEmail(email)
|
|
if err != nil || value != email || now.IsZero() {
|
|
return authmail.Subject{}, authmail.ErrUnavailable
|
|
}
|
|
return scanMailSubject(repo.store.db.QueryRowContext(ctx, mailSubjectSelect+` AND u.email_normalized=? AND EXISTS(SELECT 1 FROM gwf_verified_emails v WHERE v.user_id=u.id AND v.email_normalized=u.email_normalized)`, email))
|
|
}
|
|
|
|
func mailAuditValid(audit auth.AuditEvent, request authmail.Request, suffix string) bool {
|
|
actor := request.UserID
|
|
if request.Purpose == authmail.Reset {
|
|
actor = ""
|
|
}
|
|
return validAuditEvent(audit) && audit.ActorUserID == actor && audit.Action == "auth.mail."+string(request.Purpose)+suffix && audit.ResourceType == "user" && audit.ResourceID == request.UserID
|
|
}
|
|
|
|
func mailRequestValid(request authmail.Request) bool {
|
|
email, err := authmail.NormalizeEmail(request.Email)
|
|
if err != nil || email != request.Email || !opaqueID(request.ID) || !opaqueID(request.UserID) || request.Revision < 1 || zeroDigest(request.NewDigest) || zeroDigest(request.CredentialDigest) || request.CreatedAt.IsZero() || request.ExpiresAt.Sub(request.CreatedAt) != authmail.Lifetime {
|
|
return false
|
|
}
|
|
if request.Purpose == authmail.Change {
|
|
value, err := authmail.NormalizeEmail(request.NewEmail)
|
|
return err == nil && value == request.NewEmail && value != email && !zeroDigest(request.OldDigest) && request.OldDigest != request.NewDigest && !zeroDigest(request.SessionDigest)
|
|
}
|
|
return request.NewEmail == "" && zeroDigest(request.OldDigest) && (request.Purpose == authmail.Verify && !zeroDigest(request.SessionDigest) || request.Purpose == authmail.Reset && zeroDigest(request.SessionDigest))
|
|
}
|
|
|
|
func mailSubjectMatches(subject authmail.Subject, request authmail.Request) bool {
|
|
return subject.UserID == request.UserID && normalize(subject.Email) == request.Email && subject.Revision == request.Revision && sha256.Sum256([]byte(subject.PasswordHash)) == request.CredentialDigest && (request.Purpose != authmail.Reset || subject.Verified)
|
|
}
|
|
|
|
func mailSessionCurrent(ctx context.Context, tx *sql.Tx, request authmail.Request, now time.Time) error {
|
|
if request.Purpose == authmail.Reset {
|
|
return nil
|
|
}
|
|
var valid int
|
|
err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.user_id=? AND s.token_hash=? AND s.expires_at>? AND u.password_change_required=0`, request.UserID, request.SessionDigest[:], now.Unix()).Scan(&valid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if valid != 1 {
|
|
return authmail.ErrUnavailable
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (repo *MailRepository) Issue(ctx context.Context, request authmail.Request, messages []mail.Message, audit auth.AuditEvent) error {
|
|
if !mailRequestValid(request) || !mailAuditValid(audit, request, ".request") || !audit.CreatedAt.Equal(request.CreatedAt) {
|
|
return authmail.ErrInvalid
|
|
}
|
|
target := request.Email
|
|
if request.Purpose == authmail.Change {
|
|
target = request.NewEmail
|
|
}
|
|
if len(messages) != 1 && request.Purpose != authmail.Change || request.Purpose == authmail.Change && len(messages) != 2 {
|
|
return authmail.ErrInvalid
|
|
}
|
|
if messages[0].To != target || messages[0].Validate() != nil || !messages[0].CreatedAt.Equal(request.CreatedAt) {
|
|
return authmail.ErrInvalid
|
|
}
|
|
if len(messages) == 2 && (messages[1].To != request.Email || messages[1].Validate() != nil || messages[0].ID == messages[1].ID || !messages[1].CreatedAt.Equal(request.CreatedAt)) {
|
|
return authmail.ErrInvalid
|
|
}
|
|
tx, err := repo.store.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
// Acquire the writer lock before all identity, uniqueness and rate checks.
|
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET updated_at=updated_at WHERE id=?`, request.UserID); err != nil {
|
|
return err
|
|
}
|
|
current := repo.now().UTC()
|
|
if !request.ExpiresAt.After(current) || request.CreatedAt.After(current.Add(time.Minute)) || request.CreatedAt.Before(current.Add(-time.Minute)) {
|
|
return authmail.ErrUnavailable
|
|
}
|
|
subject, err := scanMailSubject(tx.QueryRowContext(ctx, mailSubjectSelect+` AND u.id=?`, request.UserID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !mailSubjectMatches(subject, request) {
|
|
return authmail.ErrUnavailable
|
|
}
|
|
if err = mailSessionCurrent(ctx, tx, request, current); err != nil {
|
|
return err
|
|
}
|
|
if request.Purpose == authmail.Verify && subject.Verified {
|
|
return authmail.ErrUnavailable
|
|
}
|
|
if request.Purpose == authmail.Change {
|
|
var collision int
|
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users WHERE email_normalized=?`, request.NewEmail).Scan(&collision); err != nil {
|
|
return err
|
|
}
|
|
if collision != 0 {
|
|
return authmail.ErrAddressUnavailable
|
|
}
|
|
}
|
|
var start, last int64
|
|
var count int
|
|
err = tx.QueryRowContext(ctx, `SELECT window_started,request_count,last_requested FROM gwf_account_mail_limits WHERE user_id=?`, request.UserID).Scan(&start, &count, &last)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
now := current.Unix()
|
|
if last > now-60 || start > now-3600 && count >= 5 {
|
|
return authmail.ErrLimited
|
|
}
|
|
if start <= now-3600 {
|
|
start, count = now, 0
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_limits(user_id,window_started,request_count,last_requested) VALUES(?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET window_started=excluded.window_started,request_count=excluded.request_count,last_requested=excluded.last_requested`, request.UserID, start, count+1, now); err != nil {
|
|
return err
|
|
}
|
|
var old []byte
|
|
if request.Purpose == authmail.Change {
|
|
old = request.OldDigest[:]
|
|
}
|
|
// Both token columns share one logical namespace. Detect even an entropy
|
|
// failure that collides with the other confirmation leg before inserting.
|
|
var tokenCollision int
|
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_account_mail_requests WHERE new_digest IN (?,?) OR old_digest IN (?,?)`, request.NewDigest[:], old, request.NewDigest[:], old).Scan(&tokenCollision); err != nil {
|
|
return err
|
|
}
|
|
if tokenCollision != 0 {
|
|
return authmail.ErrInvalid
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_requests(id,user_id,purpose,email,new_email,profile_revision,credential_digest,session_digest,new_digest,old_digest,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(user_id,purpose) DO UPDATE SET id=excluded.id,email=excluded.email,new_email=excluded.new_email,profile_revision=excluded.profile_revision,credential_digest=excluded.credential_digest,session_digest=excluded.session_digest,new_digest=excluded.new_digest,old_digest=excluded.old_digest,new_confirmed=0,old_confirmed=0,created_at=excluded.created_at,expires_at=excluded.expires_at`, request.ID, request.UserID, request.Purpose, request.Email, request.NewEmail, request.Revision, request.CredentialDigest[:], request.SessionDigest[:], request.NewDigest[:], old, request.CreatedAt.Unix(), request.ExpiresAt.Unix()); err != nil {
|
|
return err
|
|
}
|
|
for _, message := range messages {
|
|
if err = repo.queue.EnqueueTx(ctx, tx, message, request.ExpiresAt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
type mailQuerier interface {
|
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
|
}
|
|
|
|
func pendingMail(ctx context.Context, query mailQuerier, digest [32]byte, now time.Time) (authmail.Pending, error) {
|
|
var pending authmail.Pending
|
|
if zeroDigest(digest) || now.IsZero() {
|
|
return pending, authmail.ErrUnavailable
|
|
}
|
|
request := &pending.Request
|
|
var credential, session, next, old []byte
|
|
var created, expires int64
|
|
err := query.QueryRowContext(ctx, `SELECT id,user_id,purpose,email,new_email,profile_revision,credential_digest,session_digest,new_digest,old_digest,created_at,expires_at FROM gwf_account_mail_requests WHERE expires_at>? AND ((new_digest=? AND new_confirmed=0) OR (old_digest=? AND old_confirmed=0))`, now.Unix(), digest[:], digest[:]).Scan(&request.ID, &request.UserID, &request.Purpose, &request.Email, &request.NewEmail, &request.Revision, &credential, &session, &next, &old, &created, &expires)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return pending, authmail.ErrUnavailable
|
|
}
|
|
if err != nil {
|
|
return pending, err
|
|
}
|
|
copy(request.CredentialDigest[:], credential)
|
|
copy(request.SessionDigest[:], session)
|
|
copy(request.NewDigest[:], next)
|
|
copy(request.OldDigest[:], old)
|
|
request.CreatedAt, request.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
|
|
if !mailRequestValid(*request) {
|
|
return pending, authmail.ErrUnavailable
|
|
}
|
|
pending.OldToken = digest == request.OldDigest
|
|
pending.Subject, err = scanMailSubject(query.QueryRowContext(ctx, mailSubjectSelect+` AND u.id=?`, request.UserID))
|
|
if err != nil {
|
|
return pending, err
|
|
}
|
|
if !mailSubjectMatches(pending.Subject, *request) {
|
|
return pending, authmail.ErrUnavailable
|
|
}
|
|
if request.Purpose != authmail.Reset {
|
|
var valid int
|
|
err = query.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.user_id=? AND s.token_hash=? AND s.expires_at>? AND u.password_change_required=0`, request.UserID, request.SessionDigest[:], now.Unix()).Scan(&valid)
|
|
if err != nil {
|
|
return pending, err
|
|
}
|
|
if valid != 1 {
|
|
return pending, authmail.ErrUnavailable
|
|
}
|
|
}
|
|
return pending, nil
|
|
}
|
|
|
|
func (repo *MailRepository) Pending(ctx context.Context, digest [32]byte, now time.Time) (authmail.Pending, error) {
|
|
return pendingMail(ctx, repo.store.db, digest, now)
|
|
}
|
|
|
|
func (repo *MailRepository) Complete(ctx context.Context, digest [32]byte, requestID, newHash string, notices []mail.Message, audit auth.AuditEvent) (bool, error) {
|
|
if zeroDigest(digest) || !opaqueID(requestID) || !validAuditEvent(audit) {
|
|
return false, authmail.ErrInvalid
|
|
}
|
|
tx, err := repo.store.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_account_mail_requests SET expires_at=expires_at WHERE id=?`, requestID); err != nil {
|
|
return false, err
|
|
}
|
|
// Re-read time after acquiring the writer lock, not before password hashing
|
|
// or a database wait. A just-expired token must not complete a mutation.
|
|
audit.CreatedAt = repo.now().UTC().Truncate(time.Second)
|
|
pending, err := pendingMail(ctx, tx, digest, audit.CreatedAt)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
request := pending.Request
|
|
if request.ID != requestID || !mailAuditValid(audit, request, ".confirm") {
|
|
return false, authmail.ErrUnavailable
|
|
}
|
|
if request.Purpose == authmail.Reset {
|
|
if !strings.HasPrefix(newHash, "$argon2id$") || len(newHash) > 1024 || newHash == pending.Subject.PasswordHash || len(notices) != 1 || notices[0].To != request.Email {
|
|
return false, authmail.ErrInvalid
|
|
}
|
|
} else if newHash != "" || request.Purpose == authmail.Verify && len(notices) != 0 || request.Purpose == authmail.Change && (len(notices) != 2 || notices[0].To != request.Email || notices[1].To != request.NewEmail) {
|
|
return false, authmail.ErrInvalid
|
|
}
|
|
for _, message := range notices {
|
|
if message.Validate() != nil {
|
|
return false, authmail.ErrInvalid
|
|
}
|
|
}
|
|
column := "new_confirmed"
|
|
if pending.OldToken {
|
|
column = "old_confirmed"
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_account_mail_requests SET `+column+`=1 WHERE id=?`, requestID); err != nil {
|
|
return false, err
|
|
}
|
|
if request.Purpose == authmail.Change {
|
|
var ready bool
|
|
if err = tx.QueryRowContext(ctx, `SELECT new_confirmed=1 AND old_confirmed=1 FROM gwf_account_mail_requests WHERE id=?`, requestID).Scan(&ready); err != nil {
|
|
return false, err
|
|
}
|
|
if !ready {
|
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
|
return false, err
|
|
}
|
|
return false, tx.Commit()
|
|
}
|
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET email=?,email_normalized=?,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?) WHERE id=? AND profile_revision<9223372036854775807`, request.NewEmail, request.NewEmail, audit.CreatedAt.Unix(), request.UserID)
|
|
if err != nil {
|
|
var constraint *sqlite.Error
|
|
if errors.As(err, &constraint) && constraint.Code() == 2067 {
|
|
return false, authmail.ErrAddressUnavailable
|
|
}
|
|
return false, err
|
|
}
|
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
|
return false, authmail.ErrUnavailable
|
|
}
|
|
// Invitations issued to the previous identity do not migrate to another
|
|
// mailbox/account. Existing memberships remain bound to immutable user ID.
|
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE email_normalized=? AND used_at IS NULL AND revoked_at IS NULL`, audit.CreatedAt.Unix(), request.Email); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
if request.Purpose != authmail.Reset {
|
|
email := request.Email
|
|
if request.Purpose == authmail.Change {
|
|
email = request.NewEmail
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_verified_emails(user_id,email_normalized,verified_at) VALUES(?,?,?) ON CONFLICT(user_id) DO UPDATE SET email_normalized=excluded.email_normalized,verified_at=excluded.verified_at`, request.UserID, email, audit.CreatedAt.Unix()); err != nil {
|
|
return false, err
|
|
}
|
|
} else {
|
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=?`, newHash, audit.CreatedAt.Unix(), request.UserID); err != nil {
|
|
return false, err
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=MAX(updated_at,?) WHERE id=?`, audit.CreatedAt.Unix(), request.UserID); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
if request.Purpose != authmail.Verify {
|
|
// Keep enrolled passkeys and recovery-code digests. Revoke only sessions,
|
|
// in-flight ceremonies, enrollment/recovery grants and pending mail links.
|
|
for _, table := range []string{"gwf_auth_sessions", "gwf_passkey_ceremonies", "gwf_passkey_enrollment_tokens", "gwf_recovery_grants", "gwf_assisted_recovery_grants", "gwf_account_mail_requests"} {
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE user_id=?`, request.UserID); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
} else if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_account_mail_requests WHERE id=?`, requestID); err != nil {
|
|
return false, err
|
|
}
|
|
for _, message := range notices {
|
|
if err = repo.queue.EnqueueTx(ctx, tx, message, audit.CreatedAt.Add(24*time.Hour)); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
|
return false, err
|
|
}
|
|
return true, tx.Commit()
|
|
}
|