1package storage
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "time"
9)
10
11var (
12 ErrResetLinkInvalid = errors.New("storage: reset link is not valid")
13 ErrResetLinkDisabled = errors.New("storage: a reset link cannot be issued to a disabled account")
14 ErrResetLinkPending = errors.New("storage: a reset link cannot be issued to an account waiting for its confirmation")
15)
16
17type ResetLinkTarget struct {
18 UserID string
19 Username string
20}
21
22type IssuedResetLink struct {
23 Username string
24 ExpiresAt time.Time
25 Replaced bool
26}
27
28const resetLinkQuery = `
29SELECT r.user_id, u.username, r.created_by FROM reset_links r JOIN users u ON u.id = r.user_id
30WHERE r.token_hash = ? AND r.expires_at > ? AND u.disabled = 0`
31
32func (s *Store) IssueResetLinkAudited(ctx context.Context, userID string, tokenHash []byte, lifetime time.Duration, actor string) (IssuedResetLink, error) {
33 const operation = "issue reset link"
34 if len(tokenHash) != bootstrapHashLength {
35 return IssuedResetLink{}, invalid(operation, fmt.Errorf("token hash is not %d bytes", bootstrapHashLength))
36 }
37 if lifetime <= 0 {
38 return IssuedResetLink{}, invalid(operation, fmt.Errorf("lifetime is not positive"))
39 }
40 var issued IssuedResetLink
41 err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
42 username, _, err := userNameAndUID(ctx, tx, operation, userID)
43 if err != nil {
44 return err
45 }
46 if err := resetLinkRefusal(ctx, tx, operation, userID); err != nil {
47 return err
48 }
49 ended, err := endResetLink(ctx, tx, operation, userID)
50 if err != nil {
51 return err
52 }
53 expires := canonicalTime(at.Add(lifetime))
54 if _, err := tx.ExecContext(ctx, `INSERT INTO reset_links (user_id, token_hash, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
55 userID, tokenHash, actor, timeToInt(at), timeToInt(expires)); err != nil {
56 return classify(operation, err)
57 }
58 issued = IssuedResetLink{Username: username, ExpiresAt: expires, Replaced: ended > 0}
59 return writeAudit(ctx, tx, operation, actor, "reset_link.issue", userID, at, map[string]any{"username": username, "replaced": ended > 0})
60 })
61 if err != nil {
62 return IssuedResetLink{}, err
63 }
64 return issued, nil
65}
66
67func (s *Store) CheckResetLink(ctx context.Context, tokenHash []byte) (ResetLinkTarget, error) {
68 const operation = "check reset link"
69 if err := s.checkOpen(operation); err != nil {
70 return ResetLinkTarget{}, err
71 }
72 link, err := findResetLink(ctx, s.sql, operation, tokenHash, canonicalTime(s.now()))
73 return link.target, err
74}
75
76func (s *Store) RedeemResetLinkAudited(ctx context.Context, tokenHash, passwordHash []byte) (ResetLinkTarget, Revoked, error) {
77 const operation = "redeem reset link"
78 if err := s.checkOpen(operation); err != nil {
79 return ResetLinkTarget{}, Revoked{}, err
80 }
81 if len(passwordHash) == 0 {
82 return ResetLinkTarget{}, Revoked{}, invalid(operation, fmt.Errorf("password hash is empty"))
83 }
84 tx, err := s.sql.BeginTx(ctx, nil)
85 if err != nil {
86 return ResetLinkTarget{}, Revoked{}, classify("begin "+operation, err)
87 }
88 defer tx.Rollback()
89 at := canonicalTime(s.now())
90 link, err := findResetLink(ctx, tx, operation, tokenHash, at)
91 if err != nil {
92 return ResetLinkTarget{}, Revoked{}, err
93 }
94 target := link.target
95 used, err := execCount(ctx, tx, operation, `DELETE FROM reset_links WHERE user_id = ? AND token_hash = ?`, target.UserID, tokenHash)
96 if err != nil {
97 return ResetLinkTarget{}, Revoked{}, err
98 }
99 if used != 1 {
100 return ResetLinkTarget{}, Revoked{}, fmt.Errorf("%s: %w", operation, ErrResetLinkInvalid)
101 }
102 if _, err := execCount(ctx, tx, operation, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, target.UserID); err != nil {
103 return ResetLinkTarget{}, Revoked{}, err
104 }
105 revoked, err := revokeCredentials(ctx, tx, operation, target.UserID, "", true)
106 if err != nil {
107 return ResetLinkTarget{}, Revoked{}, err
108 }
109 fields := map[string]any{"username": target.Username}
110 if link.issuedBy.Valid {
111 fields["issued_by"] = link.issuedBy.String
112 }
113 if err := writeAudit(ctx, tx, operation, target.UserID, "reset_link.redeem", target.UserID, at, revoked.into(fields)); err != nil {
114 return ResetLinkTarget{}, Revoked{}, err
115 }
116 if err := tx.Commit(); err != nil {
117 return ResetLinkTarget{}, Revoked{}, classify("commit "+operation, err)
118 }
119 return target, revoked, nil
120}
121
122type resetLink struct {
123 target ResetLinkTarget
124 issuedBy sql.NullString
125}
126
127func findResetLink(ctx context.Context, q execer, operation string, tokenHash []byte, now time.Time) (resetLink, error) {
128 if len(tokenHash) != bootstrapHashLength {
129 return resetLink{}, fmt.Errorf("%s: %w", operation, ErrResetLinkInvalid)
130 }
131 var link resetLink
132 err := q.QueryRowContext(ctx, resetLinkQuery, tokenHash, timeToInt(now)).Scan(&link.target.UserID, &link.target.Username, &link.issuedBy)
133 if errors.Is(err, sql.ErrNoRows) {
134 return resetLink{}, fmt.Errorf("%s: %w", operation, ErrResetLinkInvalid)
135 }
136 if err != nil {
137 return resetLink{}, classify(operation, err)
138 }
139 return link, nil
140}
141
142func resetLinkRefusal(ctx context.Context, q execer, operation, userID string) error {
143 var disabled, pending int
144 err := q.QueryRowContext(ctx, `SELECT disabled, EXISTS (SELECT 1 FROM pending_confirmations WHERE user_id = users.id) FROM users WHERE id = ?`, userID).Scan(&disabled, &pending)
145 switch {
146 case errors.Is(err, sql.ErrNoRows):
147 return fmt.Errorf("%s: %w", operation, ErrNotFound)
148 case err != nil:
149 return classify(operation, err)
150 case pending != 0:
151 return fmt.Errorf("%s: %w", operation, ErrResetLinkPending)
152 case disabled != 0:
153 return fmt.Errorf("%s: %w", operation, ErrResetLinkDisabled)
154 }
155 return nil
156}
157
158func endResetLink(ctx context.Context, q execer, operation, userID string) (int64, error) {
159 return execCount(ctx, q, operation+" end reset link", `DELETE FROM reset_links WHERE user_id = ?`, userID)
160}