Raw
  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}