axe/internal/handlers/auth.go

460 lines
21 KiB
Go
Raw Normal View History

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