axe/internal/handlers/auth.go
2026-10-18 22:07:25 +03:00

460 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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})
}