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