package storage import ( "context" "database/sql" "errors" "fmt" "time" ) var ( ErrResetLinkInvalid = errors.New("storage: reset link is not valid") ErrResetLinkDisabled = errors.New("storage: a reset link cannot be issued to a disabled account") ErrResetLinkPending = errors.New("storage: a reset link cannot be issued to an account waiting for its confirmation") ) type ResetLinkTarget struct { UserID string Username string } type IssuedResetLink struct { Username string ExpiresAt time.Time Replaced bool } const resetLinkQuery = ` SELECT r.user_id, u.username, r.created_by FROM reset_links r JOIN users u ON u.id = r.user_id WHERE r.token_hash = ? AND r.expires_at > ? AND u.disabled = 0` func (s *Store) IssueResetLinkAudited(ctx context.Context, userID string, tokenHash []byte, lifetime time.Duration, actor string) (IssuedResetLink, error) { const operation = "issue reset link" if len(tokenHash) != bootstrapHashLength { return IssuedResetLink{}, invalid(operation, fmt.Errorf("token hash is not %d bytes", bootstrapHashLength)) } if lifetime <= 0 { return IssuedResetLink{}, invalid(operation, fmt.Errorf("lifetime is not positive")) } var issued IssuedResetLink err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error { username, _, err := userNameAndUID(ctx, tx, operation, userID) if err != nil { return err } if err := resetLinkRefusal(ctx, tx, operation, userID); err != nil { return err } ended, err := endResetLink(ctx, tx, operation, userID) if err != nil { return err } expires := canonicalTime(at.Add(lifetime)) if _, err := tx.ExecContext(ctx, `INSERT INTO reset_links (user_id, token_hash, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`, userID, tokenHash, actor, timeToInt(at), timeToInt(expires)); err != nil { return classify(operation, err) } issued = IssuedResetLink{Username: username, ExpiresAt: expires, Replaced: ended > 0} return writeAudit(ctx, tx, operation, actor, "reset_link.issue", userID, at, map[string]any{"username": username, "replaced": ended > 0}) }) if err != nil { return IssuedResetLink{}, err } return issued, nil } func (s *Store) CheckResetLink(ctx context.Context, tokenHash []byte) (ResetLinkTarget, error) { const operation = "check reset link" if err := s.checkOpen(operation); err != nil { return ResetLinkTarget{}, err } link, err := findResetLink(ctx, s.sql, operation, tokenHash, canonicalTime(s.now())) return link.target, err } func (s *Store) RedeemResetLinkAudited(ctx context.Context, tokenHash, passwordHash []byte) (ResetLinkTarget, Revoked, error) { const operation = "redeem reset link" if err := s.checkOpen(operation); err != nil { return ResetLinkTarget{}, Revoked{}, err } if len(passwordHash) == 0 { return ResetLinkTarget{}, Revoked{}, invalid(operation, fmt.Errorf("password hash is empty")) } tx, err := s.sql.BeginTx(ctx, nil) if err != nil { return ResetLinkTarget{}, Revoked{}, classify("begin "+operation, err) } defer tx.Rollback() at := canonicalTime(s.now()) link, err := findResetLink(ctx, tx, operation, tokenHash, at) if err != nil { return ResetLinkTarget{}, Revoked{}, err } target := link.target used, err := execCount(ctx, tx, operation, `DELETE FROM reset_links WHERE user_id = ? AND token_hash = ?`, target.UserID, tokenHash) if err != nil { return ResetLinkTarget{}, Revoked{}, err } if used != 1 { return ResetLinkTarget{}, Revoked{}, fmt.Errorf("%s: %w", operation, ErrResetLinkInvalid) } if _, err := execCount(ctx, tx, operation, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, target.UserID); err != nil { return ResetLinkTarget{}, Revoked{}, err } revoked, err := revokeCredentials(ctx, tx, operation, target.UserID, "", true) if err != nil { return ResetLinkTarget{}, Revoked{}, err } fields := map[string]any{"username": target.Username} if link.issuedBy.Valid { fields["issued_by"] = link.issuedBy.String } if err := writeAudit(ctx, tx, operation, target.UserID, "reset_link.redeem", target.UserID, at, revoked.into(fields)); err != nil { return ResetLinkTarget{}, Revoked{}, err } if err := tx.Commit(); err != nil { return ResetLinkTarget{}, Revoked{}, classify("commit "+operation, err) } return target, revoked, nil } type resetLink struct { target ResetLinkTarget issuedBy sql.NullString } func findResetLink(ctx context.Context, q execer, operation string, tokenHash []byte, now time.Time) (resetLink, error) { if len(tokenHash) != bootstrapHashLength { return resetLink{}, fmt.Errorf("%s: %w", operation, ErrResetLinkInvalid) } var link resetLink err := q.QueryRowContext(ctx, resetLinkQuery, tokenHash, timeToInt(now)).Scan(&link.target.UserID, &link.target.Username, &link.issuedBy) if errors.Is(err, sql.ErrNoRows) { return resetLink{}, fmt.Errorf("%s: %w", operation, ErrResetLinkInvalid) } if err != nil { return resetLink{}, classify(operation, err) } return link, nil } func resetLinkRefusal(ctx context.Context, q execer, operation, userID string) error { var disabled, pending int 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) switch { case errors.Is(err, sql.ErrNoRows): return fmt.Errorf("%s: %w", operation, ErrNotFound) case err != nil: return classify(operation, err) case pending != 0: return fmt.Errorf("%s: %w", operation, ErrResetLinkPending) case disabled != 0: return fmt.Errorf("%s: %w", operation, ErrResetLinkDisabled) } return nil } func endResetLink(ctx context.Context, q execer, operation, userID string) (int64, error) { return execCount(ctx, q, operation+" end reset link", `DELETE FROM reset_links WHERE user_id = ?`, userID) }