axe/internal/repository/password_reset.go
2026-10-18 22:07:25 +03:00

125 lines
5.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repository
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
// Сброс пароля без SMTP (миграция 0007): токен выдаёт админ, в базе
// остаётся только его sha256, применяется он одной SQL-операцией —
// токен нельзя потратить дважды даже при параллельных запросах.
// ResetTTL — срок жизни токена сброса. Единственный источник: и в SQL
// (expires_at), и в текстах Swagger/README.
const ResetTTL = 24 * time.Hour
// CreateResetToken — выпустить одноразовый токен смены пароля для
// пользователя. Возвращает сам токен (дальше он нигде не хранится —
// восстановить его из базы нельзя) и срок истечения.
//
// Прежние неиспользованные токены пользователя гасятся той же операцией:
// действует ровно один свежий, старый токен из письма/переписки умирает.
func (r *ProfileRepo) CreateResetToken(ctx context.Context, userID string) (string, time.Time, error) {
token, err := newResetToken()
if err != nil {
return "", time.Time{}, err
}
var expires time.Time
err = r.db.QueryRow(ctx,
`WITH dead AS (
UPDATE password_reset_tokens
SET used_at = now()
WHERE user_id = $1 AND used_at IS NULL
)
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, now() + make_interval(secs => $3))
RETURNING expires_at`,
userID, hashResetToken(token), ResetTTL.Seconds(),
).Scan(&expires)
if err != nil {
return "", time.Time{}, classify(err)
}
return token, expires, nil
}
// UseResetToken — применить токен: сменить пароль пользователя и закрыть
// сам токен. Возвращает id пользователя, чтобы вызывающий закрыл его
// refresh-сессии.
//
// Обе части — один UPDATE-CTE: токен помечается использованным и пароль
// меняется атомарно, а WHERE used_at IS NULL делает второе применение
// невозможным (конкурирующий запрос дождётся строки и увидит used_at).
func (r *ProfileRepo) UseResetToken(ctx context.Context, token, newHash string) (string, error) {
var userID string
err := r.db.QueryRow(ctx,
`WITH tok AS (
UPDATE password_reset_tokens
SET used_at = now()
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
RETURNING user_id
), upd AS (
UPDATE profiles
SET password_hash = $2
WHERE id = (SELECT user_id FROM tok)
RETURNING id
)
-- ::text обязателен: пустой литерал в контексте uuid — SQLSTATE 22P02
SELECT COALESCE((SELECT id FROM upd)::text, '')`,
hashResetToken(token), newHash,
).Scan(&userID)
if err != nil {
return "", classify(err)
}
if userID == "" {
// Неизвестен, уже потрачен или истёк — клиенту всё равно одно.
return "", ErrResetToken
}
return userID, nil
}
// SetPassword — прямая смена пароля админом (POST /users/:id/password-set):
// один UPDATE-CTE ставит новый хеш и гасит все неиспользованные токены
// сброса пользователя — после смены действующей ссылки из письма или
// переписки не остаётся. Возвращает id сменённого пользователя;
// несуществующий id — ErrNotFound (наружу 404).
func (r *ProfileRepo) SetPassword(ctx context.Context, userID, newHash string) (string, error) {
var id string
err := r.db.QueryRow(ctx,
`WITH dead AS (
UPDATE password_reset_tokens
SET used_at = now()
WHERE user_id = $1 AND used_at IS NULL
)
UPDATE profiles
SET password_hash = $2
WHERE id = $1
RETURNING id::text`,
userID, newHash,
).Scan(&id)
if err != nil {
return "", classify(err)
}
return id, nil
}
// newResetToken — 32 байта криптостойкого шума в hex (64 символа):
// не угадать, а из базы не восстановить — там только sha256.
func newResetToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generate reset token: %w", err)
}
return hex.EncodeToString(b[:]), nil
}
// hashResetToken — sha256 в hex, как у refresh-токенов в auth.HashToken.
// Своя копия здесь, чтобы репозиторий не зависел от auth-пакета.
func hashResetToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}