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