diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 90be78e..34ce823 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -2,6 +2,7 @@ package handlers import ( "regexp" + "strconv" "strings" "time" @@ -20,6 +21,8 @@ const cookieName = "buhapp_at" // setAuthCookie — кладём access-token в HttpOnly cookie // Same-origin через nginx /api proxy: cookie host-only, SameSite=Lax +// remember=true → MaxAge = 30 дней (refresh-токен живёт 30 дней в БД) +// remember=false → MaxAge = 1 час (стандарт) func setAuthCookie(c *fiber.Ctx, token string, ttl time.Duration) { c.Cookie(&fiber.Cookie{ Name: cookieName, @@ -53,6 +56,7 @@ type registerRequest struct { Gender string `json:"gender"` City string `json:"city"` Consents consentsBlock `json:"consents"` + Remember bool `json:"remember"` // "запомнить меня" } type consentsBlock struct { @@ -163,8 +167,12 @@ func (h *AuthHandler) Register(c *fiber.Ctx) error { if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token failed"}) } - // ставим access-token в HttpOnly cookie - setAuthCookie(c, tokens.Access, h.cfg.JwTAccessTTL()) + // запомнить меня — если попросят, cookie на 30 дней + accessTTL := h.cfg.JwTAccessTTL() + if req.Remember { + accessTTL = h.cfg.JwtRefreshTTL() + } + setAuthCookie(c, tokens.Access, accessTTL) return c.Status(fiber.StatusCreated).JSON(fiber.Map{ "user": fiber.Map{ @@ -176,7 +184,9 @@ func (h *AuthHandler) Register(c *fiber.Ctx) error { "gender": u.Gender, "birthdate": u.Birthdate, }, - "tokens": tokens, + "tokens": tokens, + "remember": req.Remember, + "expires_in": int(accessTTL.Seconds()), }) } @@ -184,9 +194,10 @@ type loginRequest struct { Email string `json:"email"` Phone string `json:"phone"` Password string `json:"password"` + Remember bool `json:"remember"` // "запомнить меня" — cookie на 30 дней } -// Login — фикс для Safari: разрыв keep-alive после Set-Cookie +// Login фикс для Safari + remember me func (h *AuthHandler) Login(c *fiber.Ctx) error { c.Set("Connection", "close") c.Set("Cache-Control", "no-store") @@ -194,29 +205,20 @@ func (h *AuthHandler) Login(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"}) } - req.Email = strings.TrimSpace(strings.ToLower(req.Email)) - req.Phone = strings.TrimSpace(req.Phone) - if req.Email == "" && req.Phone == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email or phone required"}) - } - if len(req.Password) < 1 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password required"}) + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + if req.Email == "" || req.Password == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email and password required"}) } - var u *users.User - var hash string - var err error - if req.Email != "" { - u, hash, err = h.users.GetByEmail(c.UserContext(), req.Email) - } else { - return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "login by phone not implemented yet"}) - } + // timing-safe lookup + u, hash, err := h.users.GetByEmail(c.UserContext(), req.Email) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"}) } // Constant-time: всегда прогоняем bcrypt даже если юзер не найден — против timing-атак if u == nil { _ = bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(req.Password)) + _ = h.audit.Log(c.UserContext(), &audit.Event{UserID: nil, Action: "login_failed", IP: c.IP(), UserAgent: c.Get("User-Agent"), Metadata: []byte(`{"reason":"no_user","email":"` + req.Email + `"}`)}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"}) } if u.IsBlocked { @@ -230,12 +232,21 @@ func (h *AuthHandler) Login(c *fiber.Ctx) error { if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token failed"}) } - // ставим access-token в HttpOnly cookie — браузер шлёт автоматически, без CORS preflight - setAuthCookie(c, tokens.Access, h.cfg.JwTAccessTTL()) + // "Запомнить меня" — если true, ставим cookie на 30 дней + accessTTL := h.cfg.JwTAccessTTL() + if req.Remember { + accessTTL = h.cfg.JwtRefreshTTL() + } + setAuthCookie(c, tokens.Access, accessTTL) + + uid := u.ID + _ = h.audit.Log(c.UserContext(), &audit.Event{UserID: &uid, Action: "login_ok", IP: c.IP(), UserAgent: c.Get("User-Agent"), Metadata: []byte(`{"remember":` + strconv.FormatBool(req.Remember) + `}`)}) return c.JSON(fiber.Map{ - "user": u, - "tokens": tokens, + "user": u, + "tokens": tokens, + "remember": req.Remember, + "expires_in": int(accessTTL.Seconds()), }) }