1package storage
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "crypto/subtle"
8 "database/sql"
9 "errors"
10 "fmt"
11 "math/big"
12 "strings"
13 "time"
14)
15
16const (
17 ConfirmCodeDigits = 6
18 ConfirmWrongLimit = 5
19)
20
21var (
22 ErrNotPending = errors.New("storage: the account is not waiting for a confirmation")
23 ErrConfirmCode = errors.New("storage: the confirmation code does not match")
24 ErrConfirmSpent = errors.New("storage: too many wrong confirmation codes; reject the account and invite again")
25 ErrUserPending = errors.New("storage: the account is waiting for its confirmation")
26 errNotExpired = errors.New("storage: the pending account has not expired")
27 confirmCodeLimit = big.NewInt(1_000_000)
28)
29
30type Pending struct {
31 UserID string
32 IssuedAt time.Time
33 ExpiresAt time.Time
34 WrongCodes int
35}
36
37func NormalizeConfirmCode(value string) (string, bool) {
38 var b strings.Builder
39 for _, r := range value {
40 switch {
41 case r >= '0' && r <= '9':
42 b.WriteRune(r)
43 case r == ' ' || r == '-' || r == '\t':
44 default:
45 return "", false
46 }
47 }
48 code := b.String()
49 return code, len(code) == ConfirmCodeDigits
50}
51
52func newConfirmCode() (string, error) {
53 n, err := rand.Int(rand.Reader, confirmCodeLimit)
54 if err != nil {
55 return "", fmt.Errorf("generate confirmation code: %w", err)
56 }
57 return fmt.Sprintf("%0*d", ConfirmCodeDigits, n.Int64()), nil
58}
59
60func confirmCodeHash(userID, code string) []byte {
61 sum := sha256.Sum256([]byte("tug.confirm\x00" + userID + "\x00" + code))
62 return sum[:]
63}
64
65func insertPending(ctx context.Context, q execer, operation, userID string, issued, expires time.Time) (string, error) {
66 code, err := newConfirmCode()
67 if err != nil {
68 return "", fmt.Errorf("%s: %w", operation, err)
69 }
70 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 {
71 return "", classify(operation+" pending", err)
72 }
73 return code, nil
74}
75
76func isPending(ctx context.Context, q execer, operation, userID string) (bool, error) {
77 var one int
78 err := q.QueryRowContext(ctx, `SELECT 1 FROM pending_confirmations WHERE user_id = ?`, userID).Scan(&one)
79 if errors.Is(err, sql.ErrNoRows) {
80 return false, nil
81 }
82 if err != nil {
83 return false, classify(operation, err)
84 }
85 return true, nil
86}
87
88func (s *Store) PendingUsers(ctx context.Context) (map[string]Pending, error) {
89 const operation = "list pending users"
90 if err := s.checkOpen(operation); err != nil {
91 return nil, err
92 }
93 rows, err := s.sql.QueryContext(ctx, `SELECT user_id, issued_at, expires_at, wrong_codes FROM pending_confirmations`)
94 if err != nil {
95 return nil, classify(operation, err)
96 }
97 defer rows.Close()
98 out := map[string]Pending{}
99 for rows.Next() {
100 var p Pending
101 var issued, expires int64
102 if err := rows.Scan(&p.UserID, &issued, &expires, &p.WrongCodes); err != nil {
103 return nil, classify(operation, err)
104 }
105 p.IssuedAt, p.ExpiresAt = intToTime(issued), intToTime(expires)
106 out[p.UserID] = p
107 }
108 return out, classify(operation, rows.Err())
109}
110
111func (s *Store) ConfirmUserAudited(ctx context.Context, userID, typed, actor string) error {
112 const operation = "confirm user"
113 var refused error
114 err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
115 var username string
116 var stored []byte
117 var wrong int
118 err := tx.QueryRowContext(ctx, `
119SELECT u.username, p.code_hash, p.wrong_codes FROM pending_confirmations p
120JOIN users u ON u.id = p.user_id WHERE p.user_id = ?`, userID).Scan(&username, &stored, &wrong)
121 switch {
122 case errors.Is(err, sql.ErrNoRows):
123 return fmt.Errorf("%s: %w", operation, ErrNotPending)
124 case err != nil:
125 return classify(operation, err)
126 }
127 if wrong >= ConfirmWrongLimit {
128 refused = fmt.Errorf("%s: %w", operation, ErrConfirmSpent)
129 return writeAudit(ctx, tx, operation, actor, "user.confirm_refused", userID, at, map[string]any{"username": username, "wrong_codes": wrong, "reason": "spent"})
130 }
131 code, ok := NormalizeConfirmCode(typed)
132 if !ok || subtle.ConstantTimeCompare(confirmCodeHash(userID, code), stored) != 1 {
133 refused = fmt.Errorf("%s: %w", operation, ErrConfirmCode)
134 if _, err := execCount(ctx, tx, operation, `UPDATE pending_confirmations SET wrong_codes = wrong_codes + 1 WHERE user_id = ?`, userID); err != nil {
135 return err
136 }
137 return writeAudit(ctx, tx, operation, actor, "user.confirm_refused", userID, at, map[string]any{"username": username, "wrong_codes": wrong + 1, "reason": "mismatch"})
138 }
139 if _, err := execCount(ctx, tx, operation, `DELETE FROM pending_confirmations WHERE user_id = ?`, userID); err != nil {
140 return err
141 }
142 if _, err := execCount(ctx, tx, operation, `UPDATE users SET disabled = 0 WHERE id = ?`, userID); err != nil {
143 return err
144 }
145 return writeAudit(ctx, tx, operation, actor, "user.confirm", userID, at, map[string]any{"username": username})
146 })
147 if err != nil {
148 return err
149 }
150 return refused
151}
152
153func blankRedeemedLabels(ctx context.Context, q execer, operation, userID string) error {
154 _, err := execCount(ctx, q, operation+" blank invitation label", `UPDATE invitations SET label = '' WHERE used_by = ?`, userID)
155 return err
156}
157
158func deletePendingUser(ctx context.Context, tx *sql.Tx, operation, userID string) (string, int, Revoked, error) {
159 username, uid, err := userNameAndUID(ctx, tx, operation, userID)
160 if err != nil {
161 return "", 0, Revoked{}, err
162 }
163 revoked, err := revokeCredentials(ctx, tx, operation, userID, "", true)
164 if err != nil {
165 return "", 0, Revoked{}, err
166 }
167 if err := blankRedeemedLabels(ctx, tx, operation, userID); err != nil {
168 return "", 0, Revoked{}, err
169 }
170 if _, err := execCount(ctx, tx, operation, `DELETE FROM users WHERE id = ?`, userID); err != nil {
171 return "", 0, Revoked{}, err
172 }
173 return username, uid, revoked, nil
174}
175
176func (s *Store) RejectPendingUserAudited(ctx context.Context, userID, actor string) (Revoked, error) {
177 const operation = "reject pending user"
178 var revoked Revoked
179 err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
180 pending, err := isPending(ctx, tx, operation, userID)
181 if err != nil {
182 return err
183 }
184 if !pending {
185 return fmt.Errorf("%s: %w", operation, ErrNotPending)
186 }
187 username, uid, r, err := deletePendingUser(ctx, tx, operation, userID)
188 if err != nil {
189 return err
190 }
191 revoked = r
192 return writeAudit(ctx, tx, operation, actor, "user.reject", userID, at, r.into(map[string]any{"username": username, "uid_number": uid}))
193 })
194 if err != nil {
195 return Revoked{}, err
196 }
197 return revoked, nil
198}
199
200func (s *Store) SweepPendingAccounts(ctx context.Context) (int, error) {
201 const operation = "sweep pending accounts"
202 if err := s.checkOpen(operation); err != nil {
203 return 0, err
204 }
205 now := canonicalTime(s.now())
206 ids, err := queryIDs(ctx, s.sql, operation, `
207SELECT user_id FROM pending_confirmations
208WHERE expires_at <= ?
209ORDER BY expires_at, user_id`, timeToInt(now))
210 if err != nil {
211 return 0, err
212 }
213 swept := 0
214 for _, id := range ids {
215 err := s.auditedAt(ctx, operation, "system", now, func(tx *sql.Tx, at time.Time) error {
216 var expires int64
217 err := tx.QueryRowContext(ctx, `SELECT expires_at FROM pending_confirmations WHERE user_id = ?`, id).Scan(&expires)
218 switch {
219 case errors.Is(err, sql.ErrNoRows):
220 return errNotExpired
221 case err != nil:
222 return classify(operation, err)
223 case expires > timeToInt(at):
224 return errNotExpired
225 }
226 username, uid, revoked, err := deletePendingUser(ctx, tx, operation, id)
227 if err != nil {
228 return err
229 }
230 return writeAudit(ctx, tx, operation, "system", "user.pending_expired", id, at, revoked.into(map[string]any{"username": username, "uid_number": uid}))
231 })
232 switch {
233 case errors.Is(err, errNotExpired):
234 case err != nil:
235 return swept, err
236 default:
237 swept++
238 }
239 }
240 return swept, nil
241}
242
243type rowQuerier interface {
244 QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
245}
246
247func queryIDs(ctx context.Context, q rowQuerier, operation, query string, args ...any) ([]string, error) {
248 rows, err := q.QueryContext(ctx, query, args...)
249 if err != nil {
250 return nil, classify(operation, err)
251 }
252 defer rows.Close()
253 var ids []string
254 for rows.Next() {
255 var id string
256 if err := rows.Scan(&id); err != nil {
257 return nil, classify(operation, err)
258 }
259 ids = append(ids, id)
260 }
261 return ids, classify(operation, rows.Err())
262}