125 lines
5.2 KiB
Go
125 lines
5.2 KiB
Go
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[:])
|
||
}
|