460 lines
21 KiB
Go
460 lines
21 KiB
Go
|
|
// Package handlers — HTTP-слой: хендлеры gin, разбор тел запросов и
|
|||
|
|
// маппинг ошибок репозиториев в коды ответа. Никакой бизнес-логики —
|
|||
|
|
// только проверки прав, валидация и вызов репозиториев.
|
|||
|
|
package handlers
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"errors"
|
|||
|
|
"log/slog"
|
|||
|
|
"net/http"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
"github.com/google/uuid"
|
|||
|
|
|
|||
|
|
"building-mgmt/internal/auth"
|
|||
|
|
"building-mgmt/internal/mailer"
|
|||
|
|
"building-mgmt/internal/models"
|
|||
|
|
"building-mgmt/internal/ratelimit"
|
|||
|
|
"building-mgmt/internal/repository"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// AuthHandler — эндпоинты входа, работы с сессиями и сброса пароля.
|
|||
|
|
// Создания аккаунтов здесь нет: их заводит только админ (POST /users).
|
|||
|
|
type AuthHandler struct {
|
|||
|
|
repo *repository.ProfileRepo
|
|||
|
|
rtRepo *repository.RefreshTokenRepo
|
|||
|
|
jwtSecret string
|
|||
|
|
// cookieSecure — ставить ли Secure-флаг на refresh-куку: браузер
|
|||
|
|
// отвергает Secure-куку по HTTP, поэтому локально флаг выключен,
|
|||
|
|
// в проде за TLS включается через COOKIE_SECURE=true.
|
|||
|
|
cookieSecure bool
|
|||
|
|
mailer mailer.Mailer
|
|||
|
|
// forgotMail — лимит писем «забыли пароль» на ящик (3 за сутки),
|
|||
|
|
// ключ — lower(email). Тикает ДО поиска в базе и всегда: счётчик
|
|||
|
|
// одинаков для существующих и несуществующих адресов, иначе 429
|
|||
|
|
// выдавал бы, что ящик зарегистрирован.
|
|||
|
|
forgotMail ratelimit.Allower
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewAuthHandler собирает хендлер: профили, refresh-сессии, секрет
|
|||
|
|
// подписи, почта сброса и лимит писем на ящик.
|
|||
|
|
func NewAuthHandler(repo *repository.ProfileRepo, rtRepo *repository.RefreshTokenRepo, secret string, cookieSecure bool, ml mailer.Mailer, forgotMail ratelimit.Allower) *AuthHandler {
|
|||
|
|
return &AuthHandler{
|
|||
|
|
repo: repo,
|
|||
|
|
rtRepo: rtRepo,
|
|||
|
|
jwtSecret: secret,
|
|||
|
|
cookieSecure: cookieSecure,
|
|||
|
|
mailer: ml,
|
|||
|
|
forgotMail: forgotMail,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// refreshCookieName — имя куки с refresh-токеном.
|
|||
|
|
const refreshCookieName = "refresh_token"
|
|||
|
|
|
|||
|
|
// setRefreshCookie ставит refresh-куку: HttpOnly (JS недоступен — XSS не
|
|||
|
|
// утащит сессию), SameSite=Lax (в cross-site POST кука не уходит — это и
|
|||
|
|
// есть защита от CSRF на refresh/logout), Path=/auth (кука летает только
|
|||
|
|
// к эндпоинтам сессии, а не к каждому запросу).
|
|||
|
|
func (h *AuthHandler) setRefreshCookie(c *gin.Context, value string) {
|
|||
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|||
|
|
Name: refreshCookieName,
|
|||
|
|
Value: value,
|
|||
|
|
Path: "/auth",
|
|||
|
|
MaxAge: int(auth.RefreshTTL.Seconds()),
|
|||
|
|
HttpOnly: true,
|
|||
|
|
Secure: h.cookieSecure,
|
|||
|
|
SameSite: http.SameSiteLaxMode,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// clearRefreshCookie гасит куку (MaxAge<0 — браузер удаляет её сразу).
|
|||
|
|
func (h *AuthHandler) clearRefreshCookie(c *gin.Context) {
|
|||
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|||
|
|
Name: refreshCookieName,
|
|||
|
|
Value: "",
|
|||
|
|
Path: "/auth",
|
|||
|
|
MaxAge: -1,
|
|||
|
|
HttpOnly: true,
|
|||
|
|
Secure: h.cookieSecure,
|
|||
|
|
SameSite: http.SameSiteLaxMode,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// issueTokens — выпустить пару «короткий access + ротируемый refresh».
|
|||
|
|
// Единая точка выдачи: login и refresh отвечают одинаково, и ни один
|
|||
|
|
// из вызовов не забудет про refresh-токен: секрет уходит только в
|
|||
|
|
// httpOnly-куку, в JSON его не отдаём.
|
|||
|
|
func (h *AuthHandler) issueTokens(c *gin.Context, p *models.Profile) (*TokenResponse, error) {
|
|||
|
|
access, err := auth.GenerateToken(h.jwtSecret, p.ID, p.Role)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
secret, err := auth.NewRefreshToken()
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
err = h.rtRepo.Create(
|
|||
|
|
c.Request.Context(),
|
|||
|
|
uuid.NewString(),
|
|||
|
|
p.ID,
|
|||
|
|
auth.HashToken(secret),
|
|||
|
|
time.Now().Add(auth.RefreshTTL),
|
|||
|
|
c.Request.UserAgent(),
|
|||
|
|
c.ClientIP(),
|
|||
|
|
)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
h.setRefreshCookie(c, secret)
|
|||
|
|
return &TokenResponse{
|
|||
|
|
Token: access,
|
|||
|
|
ExpiresIn: int(auth.AccessTTL.Seconds()),
|
|||
|
|
User: p,
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type loginReq struct {
|
|||
|
|
Email string `json:"email" binding:"required"`
|
|||
|
|
Password string `json:"password" binding:"required"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Login — POST /auth/login.
|
|||
|
|
//
|
|||
|
|
// @Summary Вход
|
|||
|
|
// @Description Возвращает access-токен; refresh-токен — только в httpOnly-куке (Path=/auth, SameSite=Lax, Secure — через COOKIE_SECURE), в JSON его нет. Email ищется без учёта регистра, пароль — bcrypt. Неподтверждённый аккаунт — 403 account not confirmed (отличайте от account disabled). Лимит: 10 попыток в минуту с одного IP.
|
|||
|
|
// @Tags auth
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param body body loginReq true "Учётные данные"
|
|||
|
|
// @Success 200 {object} handlers.TokenResponse
|
|||
|
|
// @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса"
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "неверный логин или пароль"
|
|||
|
|
// @Failure 403 {object} handlers.ErrorResponse "аккаунт отключён либо не подтверждён"
|
|||
|
|
// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток"
|
|||
|
|
// @Router /auth/login [post]
|
|||
|
|
func (h *AuthHandler) Login(c *gin.Context) {
|
|||
|
|
var req loginReq
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
bindingError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
profile, err := h.repo.GetByEmail(c.Request.Context(), req.Email)
|
|||
|
|
if errors.Is(err, repository.ErrNotFound) {
|
|||
|
|
// Не подсказываем, какой именно логин неверен.
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !auth.CheckPassword(profile.PasswordHash, req.Password) {
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !profile.IsActive {
|
|||
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "account disabled"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Отдельно от «disabled»: неподтверждённый — не отключён, ему
|
|||
|
|
// просто ждём подтверждения от админа (POST /users/:id/confirm).
|
|||
|
|
if profile.ConfirmedAt == nil {
|
|||
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "account not confirmed"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp, err := h.issueTokens(c, profile)
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, resp)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Refresh — POST /auth/refresh. Обновить пару токенов.
|
|||
|
|
// Тело не нужно: refresh-токен приходит в httpOnly-куке, браузер
|
|||
|
|
// отправляет её сам (same-origin). Каждый вызов даёт новую пару,
|
|||
|
|
// старая сессия гасится (ротация): украденный refresh-токен живёт
|
|||
|
|
// не дольше одного использования.
|
|||
|
|
// Если отозванный токен предъявляют повторно — это признак того, что
|
|||
|
|
// секретом пользуется не тот клиент, поэтому гасим все сессии пользователя.
|
|||
|
|
// @Summary Обновить пару токенов
|
|||
|
|
// @Description Refresh-токен читается из httpOnly-куки refresh_token (Path=/auth), тело запроса не требуется. Ротация: на выходе новый access-токен и новая кука, старая сессия гасится. Повторное использование отозванного токена закрывает все сессии пользователя — признак того, что секретом владеет кто-то второй.
|
|||
|
|
// @Tags auth
|
|||
|
|
// @Produce json
|
|||
|
|
// @Success 200 {object} handlers.TokenResponse
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "куки нет, токен неизвестен, отозван или истёк"
|
|||
|
|
// @Failure 403 {object} handlers.ErrorResponse "аккаунт отключён"
|
|||
|
|
// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток"
|
|||
|
|
// @Router /auth/refresh [post]
|
|||
|
|
func (h *AuthHandler) Refresh(c *gin.Context) {
|
|||
|
|
secret, err := c.Cookie(refreshCookieName)
|
|||
|
|
if err != nil || secret == "" {
|
|||
|
|
// Нет куки — неотличимо от неизвестного токена: лишних
|
|||
|
|
// подробностей перебору не даём.
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
hash := auth.HashToken(secret)
|
|||
|
|
|
|||
|
|
rt, err := h.rtRepo.GetByHash(ctx, hash)
|
|||
|
|
if errors.Is(err, repository.ErrNotFound) {
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if rt.RevokedAt != nil {
|
|||
|
|
// Повторное использование отозванного токена — признак того, что
|
|||
|
|
// секретом владеет кто-то второй. Гасим все сессии пользователя;
|
|||
|
|
// клиенту всё равно «invalid refresh token», деталь — в лог.
|
|||
|
|
if n, err := h.rtRepo.RevokeAllForUser(ctx, rt.UserID); err == nil && n > 0 {
|
|||
|
|
slog.Warn("security: refresh token reuse detected",
|
|||
|
|
"user_id", rt.UserID, "sessions_revoked", n)
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if time.Now().After(rt.ExpiresAt) {
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token expired"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
profile, err := h.repo.GetByID(ctx, rt.UserID)
|
|||
|
|
if errors.Is(err, repository.ErrNotFound) {
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if !profile.IsActive {
|
|||
|
|
// Деактивированный не получает новую сессию даже с валидным токеном.
|
|||
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "account disabled"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp, err := h.issueTokens(c, profile)
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Ротация: новая сессия уже создана, старую гасим. Если гас не вышел —
|
|||
|
|
// это не повод отдавать 500: клиент получил рабочую пару, а обе
|
|||
|
|
// валидные сессии — менее страшно, чем отдать 500 на удачном обновлении.
|
|||
|
|
if _, err := h.rtRepo.RevokeByHash(ctx, hash); err != nil {
|
|||
|
|
slog.Warn("refresh rotation: failed to revoke old session", "err", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, resp)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Logout — POST /auth/logout. Гасит refresh-сессию из httpOnly-куки
|
|||
|
|
// и саму куку очищает. Auth-middleware намеренно не используется:
|
|||
|
|
// выйти должен пользователь и с протухшим access-токеном. Ответ неотличим
|
|||
|
|
// для отсутствующего токена — чужой не должен узнавать, был ли токен в базе.
|
|||
|
|
// @Summary Выйти (одно устройство)
|
|||
|
|
// @Description Гасит refresh-сессию из куки refresh_token и очищает куку. Тело запроса не нужно. Auth-middleware намеренно не используется: выйти должен пользователь и с протухшим access-токеном.
|
|||
|
|
// @Tags auth
|
|||
|
|
// @Produce json
|
|||
|
|
// @Success 200 {object} handlers.OKResponse
|
|||
|
|
// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток"
|
|||
|
|
// @Router /auth/logout [post]
|
|||
|
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
|||
|
|
h.clearRefreshCookie(c)
|
|||
|
|
|
|||
|
|
secret, err := c.Cookie(refreshCookieName)
|
|||
|
|
if err != nil || secret == "" {
|
|||
|
|
// Куки нет — и гасить нечего: выход идемпотентен.
|
|||
|
|
c.JSON(http.StatusOK, &OKResponse{OK: true})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if _, err := h.rtRepo.RevokeByHash(c.Request.Context(), auth.HashToken(secret)); err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, &OKResponse{OK: true})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// LogoutAll — POST /auth/logout-all. Закрыть все сессии текущего
|
|||
|
|
// пользователя («выйти со всех устройств»). Требует валидного access-токена.
|
|||
|
|
// @Summary Выйти со всех устройств
|
|||
|
|
// @Description Закрывает все сессии текущего пользователя.
|
|||
|
|
// @Tags auth
|
|||
|
|
// @Produce json
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Success 200 {object} handlers.OKResponse
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
|||
|
|
// @Router /auth/logout-all [post]
|
|||
|
|
func (h *AuthHandler) LogoutAll(c *gin.Context) {
|
|||
|
|
n, err := h.rtRepo.RevokeAllForUser(c.Request.Context(), c.GetString("user_id"))
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, &OKResponse{OK: true, Revoked: n})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Me — GET /auth/me. Профиль текущего авторизованного пользователя.
|
|||
|
|
//
|
|||
|
|
// @Summary Текущий пользователь
|
|||
|
|
// @Tags auth
|
|||
|
|
// @Produce json
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Success 200 {object} models.Profile
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
|||
|
|
// @Router /auth/me [get]
|
|||
|
|
func (h *AuthHandler) Me(c *gin.Context) {
|
|||
|
|
profile, err := h.repo.GetByID(c.Request.Context(), c.GetString("user_id"))
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, profile)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type forgotPasswordReq struct {
|
|||
|
|
Email string `json:"email" binding:"required,email"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ForgotPassword — POST /auth/forgot-password. Письмо со ссылкой сброса.
|
|||
|
|
//
|
|||
|
|
// @Summary Запрос письма для сброса пароля
|
|||
|
|
// @Description Отправляет на email письмо со ссылкой формы сброса (токен живёт 24 часа, одноразовый). Ответ всегда одинаковый — 200 с ok:true, независимо от того, существует ящик, подтверждён он и активен: эндпоинт не перечисляет аккаунты. Письмо уходит только подтверждённым активным аккаунтам. Лимиты: 10 запросов в минуту с одного IP; 3 письма в сутки на один ящик (счётчик тикает при любом запросе — и для несуществующих ящиков тоже), при превышении — 429.
|
|||
|
|
// @Tags auth
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param body body forgotPasswordReq true "Email для восстановления"
|
|||
|
|
// @Success 200 {object} handlers.OKResponse "всегда ok:true, даже если ящик не найден"
|
|||
|
|
// @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса"
|
|||
|
|
// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток"
|
|||
|
|
// @Router /auth/forgot-password [post]
|
|||
|
|
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
|||
|
|
var req forgotPasswordReq
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
bindingError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Лимит на ящик — до всякого поиска в базе и независимо от его
|
|||
|
|
// результата: счётчик одинаков для существующих и несуществующих
|
|||
|
|
// адресов, иначе 429 раскрыл бы, что ящик зарегистрирован.
|
|||
|
|
// Ключ — lower(email) в обоих регистрах: поиск ниже идёт по lower().
|
|||
|
|
if !h.forgotMail.Allow(strings.ToLower(req.Email)) {
|
|||
|
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
profile, err := h.repo.GetByEmail(ctx, req.Email)
|
|||
|
|
// Не найден, не подтверждён или выключен — ответ тот же, что при
|
|||
|
|
// успехе: перечисление ящиков через этот эндпоинт невозможно.
|
|||
|
|
// Письмо в этих случаях не шлём.
|
|||
|
|
if errors.Is(err, repository.ErrNotFound) {
|
|||
|
|
c.JSON(http.StatusOK, OKResponse{OK: true})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if profile.ConfirmedAt == nil || !profile.IsActive {
|
|||
|
|
c.JSON(http.StatusOK, OKResponse{OK: true})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Прежние токены пользователя гаснут внутри CreateResetToken —
|
|||
|
|
// в живой остаётся только последняя ссылка из свежего письма.
|
|||
|
|
token, expires, err := h.repo.CreateResetToken(ctx, profile.ID)
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fail-open, как в confirm: подтверждение уже не откатывается, а
|
|||
|
|
// здесь — письмо не повод отвечать 500 (ящик существует — не будем
|
|||
|
|
// это и подтверждать). Ошибка SMTP уходит в warn.
|
|||
|
|
if err := h.mailer.SendPasswordReset(ctx, mailer.PasswordReset{
|
|||
|
|
To: profile.Email,
|
|||
|
|
Name: profile.Name,
|
|||
|
|
Token: token,
|
|||
|
|
ExpiresAt: expires,
|
|||
|
|
}); err != nil {
|
|||
|
|
slog.Warn("password reset email failed", "err", err, "user_id", profile.ID)
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, OKResponse{OK: true})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type resetPasswordReq struct {
|
|||
|
|
Token string `json:"token" binding:"required"`
|
|||
|
|
Password string `json:"password" binding:"required"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ResetPassword — POST /auth/reset-password. Смена пароля по токену.
|
|||
|
|
//
|
|||
|
|
// @Summary Сброс пароля по токену
|
|||
|
|
// @Description Токен одноразовый и живёт 24 часа: приходит письмом после POST /auth/forgot-password либо выдаётся админом через POST /users/{id}/password-reset. Новый пароль — по политике (10..72 символа, буквы и цифры). Успех гасит токен и все refresh-сессии пользователя. Лимит: 30 попыток в минуту с одного IP.
|
|||
|
|
// @Tags auth
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param body body resetPasswordReq true "Токен и новый пароль"
|
|||
|
|
// @Success 200 {object} handlers.OKResponse
|
|||
|
|
// @Failure 400 {object} handlers.ErrorResponse "некорректное тело или токен неизвестен, использован либо истёк; слабый пароль"
|
|||
|
|
// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток"
|
|||
|
|
// @Router /auth/reset-password [post]
|
|||
|
|
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
|||
|
|
var req resetPasswordReq
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
bindingError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := auth.ValidatePassword(req.Password); err != nil {
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
hash, err := auth.HashPassword(req.Password)
|
|||
|
|
if err != nil {
|
|||
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash failed"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
userID, err := h.repo.UseResetToken(ctx, req.Token, hash)
|
|||
|
|
if errors.Is(err, repository.ErrResetToken) {
|
|||
|
|
// Причину не раскрываем: неизвестен, использован или истёк —
|
|||
|
|
// для перебора это одно и то же.
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Пароль сменился — все сессии закрываем: прежний владелец не должен
|
|||
|
|
// остаться в системе с access-токеном (он живёт до часа) или с
|
|||
|
|
// refresh-сессией. Ошибка разлогина — не повод отдавать 500:
|
|||
|
|
// пароль-то уже обновлён, деталь уходит в лог.
|
|||
|
|
if n, err := h.rtRepo.RevokeAllForUser(ctx, userID); err != nil {
|
|||
|
|
slog.Warn("password reset: failed to revoke sessions", "user_id", userID, "err", err)
|
|||
|
|
} else {
|
|||
|
|
slog.Info("security: password reset", "user_id", userID, "sessions_revoked", n)
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, OKResponse{OK: true})
|
|||
|
|
}
|