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[:])
|
|||
|
|
}
|