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