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