axe/internal/repository/password_reset.go

125 lines
5.2 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
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[:])
}