package storage import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "database/sql" "errors" "fmt" "math/big" "strings" "time" ) const ( ConfirmCodeDigits = 6 ConfirmWrongLimit = 5 ) var ( ErrNotPending = errors.New("storage: the account is not waiting for a confirmation") ErrConfirmCode = errors.New("storage: the confirmation code does not match") ErrConfirmSpent = errors.New("storage: too many wrong confirmation codes; reject the account and invite again") ErrUserPending = errors.New("storage: the account is waiting for its confirmation") errNotExpired = errors.New("storage: the pending account has not expired") confirmCodeLimit = big.NewInt(1_000_000) ) type Pending struct { UserID string IssuedAt time.Time ExpiresAt time.Time WrongCodes int } func NormalizeConfirmCode(value string) (string, bool) { var b strings.Builder for _, r := range value { switch { case r >= '0' && r <= '9': b.WriteRune(r) case r == ' ' || r == '-' || r == '\t': default: return "", false } } code := b.String() return code, len(code) == ConfirmCodeDigits } func newConfirmCode() (string, error) { n, err := rand.Int(rand.Reader, confirmCodeLimit) if err != nil { return "", fmt.Errorf("generate confirmation code: %w", err) } return fmt.Sprintf("%0*d", ConfirmCodeDigits, n.Int64()), nil } func confirmCodeHash(userID, code string) []byte { sum := sha256.Sum256([]byte("tug.confirm\x00" + userID + "\x00" + code)) return sum[:] } func insertPending(ctx context.Context, q execer, operation, userID string, issued, expires time.Time) (string, error) { code, err := newConfirmCode() if err != nil { return "", fmt.Errorf("%s: %w", operation, err) } if _, err := q.ExecContext(ctx, `INSERT INTO pending_confirmations (user_id, code_hash, issued_at, expires_at) VALUES (?, ?, ?, ?)`, userID, confirmCodeHash(userID, code), timeToInt(issued), timeToInt(expires)); err != nil { return "", classify(operation+" pending", err) } return code, nil } func isPending(ctx context.Context, q execer, operation, userID string) (bool, error) { var one int err := q.QueryRowContext(ctx, `SELECT 1 FROM pending_confirmations WHERE user_id = ?`, userID).Scan(&one) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, classify(operation, err) } return true, nil } func (s *Store) PendingUsers(ctx context.Context) (map[string]Pending, error) { const operation = "list pending users" if err := s.checkOpen(operation); err != nil { return nil, err } rows, err := s.sql.QueryContext(ctx, `SELECT user_id, issued_at, expires_at, wrong_codes FROM pending_confirmations`) if err != nil { return nil, classify(operation, err) } defer rows.Close() out := map[string]Pending{} for rows.Next() { var p Pending var issued, expires int64 if err := rows.Scan(&p.UserID, &issued, &expires, &p.WrongCodes); err != nil { return nil, classify(operation, err) } p.IssuedAt, p.ExpiresAt = intToTime(issued), intToTime(expires) out[p.UserID] = p } return out, classify(operation, rows.Err()) } func (s *Store) ConfirmUserAudited(ctx context.Context, userID, typed, actor string) error { const operation = "confirm user" var refused error err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error { var username string var stored []byte var wrong int err := tx.QueryRowContext(ctx, ` SELECT u.username, p.code_hash, p.wrong_codes FROM pending_confirmations p JOIN users u ON u.id = p.user_id WHERE p.user_id = ?`, userID).Scan(&username, &stored, &wrong) switch { case errors.Is(err, sql.ErrNoRows): return fmt.Errorf("%s: %w", operation, ErrNotPending) case err != nil: return classify(operation, err) } if wrong >= ConfirmWrongLimit { refused = fmt.Errorf("%s: %w", operation, ErrConfirmSpent) return writeAudit(ctx, tx, operation, actor, "user.confirm_refused", userID, at, map[string]any{"username": username, "wrong_codes": wrong, "reason": "spent"}) } code, ok := NormalizeConfirmCode(typed) if !ok || subtle.ConstantTimeCompare(confirmCodeHash(userID, code), stored) != 1 { refused = fmt.Errorf("%s: %w", operation, ErrConfirmCode) if _, err := execCount(ctx, tx, operation, `UPDATE pending_confirmations SET wrong_codes = wrong_codes + 1 WHERE user_id = ?`, userID); err != nil { return err } return writeAudit(ctx, tx, operation, actor, "user.confirm_refused", userID, at, map[string]any{"username": username, "wrong_codes": wrong + 1, "reason": "mismatch"}) } if _, err := execCount(ctx, tx, operation, `DELETE FROM pending_confirmations WHERE user_id = ?`, userID); err != nil { return err } if _, err := execCount(ctx, tx, operation, `UPDATE users SET disabled = 0 WHERE id = ?`, userID); err != nil { return err } return writeAudit(ctx, tx, operation, actor, "user.confirm", userID, at, map[string]any{"username": username}) }) if err != nil { return err } return refused } func blankRedeemedLabels(ctx context.Context, q execer, operation, userID string) error { _, err := execCount(ctx, q, operation+" blank invitation label", `UPDATE invitations SET label = '' WHERE used_by = ?`, userID) return err } func deletePendingUser(ctx context.Context, tx *sql.Tx, operation, userID string) (string, int, Revoked, error) { username, uid, err := userNameAndUID(ctx, tx, operation, userID) if err != nil { return "", 0, Revoked{}, err } revoked, err := revokeCredentials(ctx, tx, operation, userID, "", true) if err != nil { return "", 0, Revoked{}, err } if err := blankRedeemedLabels(ctx, tx, operation, userID); err != nil { return "", 0, Revoked{}, err } if _, err := execCount(ctx, tx, operation, `DELETE FROM users WHERE id = ?`, userID); err != nil { return "", 0, Revoked{}, err } return username, uid, revoked, nil } func (s *Store) RejectPendingUserAudited(ctx context.Context, userID, actor string) (Revoked, error) { const operation = "reject pending user" var revoked Revoked err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error { pending, err := isPending(ctx, tx, operation, userID) if err != nil { return err } if !pending { return fmt.Errorf("%s: %w", operation, ErrNotPending) } username, uid, r, err := deletePendingUser(ctx, tx, operation, userID) if err != nil { return err } revoked = r return writeAudit(ctx, tx, operation, actor, "user.reject", userID, at, r.into(map[string]any{"username": username, "uid_number": uid})) }) if err != nil { return Revoked{}, err } return revoked, nil } func (s *Store) SweepPendingAccounts(ctx context.Context) (int, error) { const operation = "sweep pending accounts" if err := s.checkOpen(operation); err != nil { return 0, err } now := canonicalTime(s.now()) ids, err := queryIDs(ctx, s.sql, operation, ` SELECT user_id FROM pending_confirmations WHERE expires_at <= ? ORDER BY expires_at, user_id`, timeToInt(now)) if err != nil { return 0, err } swept := 0 for _, id := range ids { err := s.auditedAt(ctx, operation, "system", now, func(tx *sql.Tx, at time.Time) error { var expires int64 err := tx.QueryRowContext(ctx, `SELECT expires_at FROM pending_confirmations WHERE user_id = ?`, id).Scan(&expires) switch { case errors.Is(err, sql.ErrNoRows): return errNotExpired case err != nil: return classify(operation, err) case expires > timeToInt(at): return errNotExpired } username, uid, revoked, err := deletePendingUser(ctx, tx, operation, id) if err != nil { return err } return writeAudit(ctx, tx, operation, "system", "user.pending_expired", id, at, revoked.into(map[string]any{"username": username, "uid_number": uid})) }) switch { case errors.Is(err, errNotExpired): case err != nil: return swept, err default: swept++ } } return swept, nil } type rowQuerier interface { QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) } func queryIDs(ctx context.Context, q rowQuerier, operation, query string, args ...any) ([]string, error) { rows, err := q.QueryContext(ctx, query, args...) if err != nil { return nil, classify(operation, err) } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, classify(operation, err) } ids = append(ids, id) } return ids, classify(operation, rows.Err()) }