buhapp-backend/internal/handlers/auth.go
ga 8cb0a066b8 Fix Safari login: Connection:close + SameSite=None (Grok analysis)
Safari блокирует fetch после POST с Set-Cookie из-за keep-alive/HTTP2.
- Login/Register: Connection: close, Cache-Control: no-store
- Cookie: SameSite=None (кросс-поддомен buhapp → api.buhapp)
2026-08-21 10:57:22 +00:00

264 lines
8.5 KiB
Go
Raw 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
import (
"regexp"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"github.com/buhapp/backend/internal/audit"
"github.com/buhapp/backend/internal/auth"
"github.com/buhapp/backend/internal/config"
"github.com/buhapp/backend/internal/consent"
"github.com/buhapp/backend/internal/users"
)
const cookieName = "buhapp_at"
// setAuthCookie — кладём access-token в HttpOnly cookie (CORS-friendly, без preflight)
func setAuthCookie(c *fiber.Ctx, token string, ttl time.Duration) {
// SameSite=Lax работает только same-site (subdomain.co → api.subdomain.co)
// Cross-site нужен SameSite=None + Secure
// buhapp.mygoodservice.ru → api.buhapp.mygoodservice.ru — same-site в браузерной семантике
// Но Safari трактует cookie strictly — Lax достаточно для top-level navigation, не для fetch
// Поэтому: SameSite=None + Secure (обязательно для Safari кросс-поддомен)
c.Cookie(&fiber.Cookie{
Name: cookieName,
Value: token,
Path: "/",
MaxAge: int(ttl.Seconds()),
HTTPOnly: true,
Secure: true,
SameSite: "None",
})
}
type AuthHandler struct {
cfg *config.Config
users *users.Repo
consent *consent.Repo
audit *audit.Repo
auth *auth.Service
}
func NewAuthHandler(cfg *config.Config, u *users.Repo, c *consent.Repo, a *audit.Repo, au *auth.Service) *AuthHandler {
return &AuthHandler{cfg: cfg, users: u, consent: c, audit: a, auth: au}
}
type registerRequest struct {
Email string `json:"email"`
Phone string `json:"phone"`
Password string `json:"password"`
Name string `json:"name"`
Birthdate string `json:"birthdate"`
Gender string `json:"gender"`
City string `json:"city"`
Consents consentsBlock `json:"consents"`
}
type consentsBlock struct {
Adult bool `json:"adult"`
Terms bool `json:"terms"`
Privacy bool `json:"privacy"`
Disclaimer bool `json:"disclaimer"`
}
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
var phoneRe = regexp.MustCompile(`^\+?[0-9]{10,15}$`)
// dummy bcrypt hash для constant-time при user enumeration
const dummyHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
func (h *AuthHandler) Register(c *fiber.Ctx) error {
c.Set("Connection", "close")
c.Set("Cache-Control", "no-store")
var req registerRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
if !req.Consents.Adult || !req.Consents.Terms || !req.Consents.Privacy || !req.Consents.Disclaimer {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "all consents required: adult, terms, privacy, disclaimer",
})
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
req.Phone = strings.TrimSpace(req.Phone)
req.Name = strings.TrimSpace(req.Name)
if req.Email == "" && req.Phone == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email or phone required"})
}
if req.Email != "" && !emailRe.MatchString(req.Email) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid email"})
}
if req.Phone != "" && !phoneRe.MatchString(req.Phone) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid phone"})
}
if len(req.Password) < 8 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password must be at least 8 chars"})
}
if req.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name required"})
}
var birth *time.Time
if req.Birthdate != "" {
t, err := time.Parse("2006-01-02", req.Birthdate)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "birthdate must be YYYY-MM-DD"})
}
if t.After(time.Now().AddDate(-18, 0, 0)) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "must be 18+"})
}
birth = &t
}
if req.Gender != "" && req.Gender != "m" && req.Gender != "f" && req.Gender != "o" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "gender must be m, f or o"})
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "hash failed"})
}
u := &users.User{
Email: req.Email,
Phone: req.Phone,
Name: req.Name,
Birthdate: birth,
Gender: req.Gender,
City: req.City,
}
if err := h.users.CreateWithPassword(c.UserContext(), u, string(hash)); err != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "user already exists"})
}
ip := c.IP()
ua := c.Get("User-Agent")
for _, item := range []struct {
Type consent.DocType
Ver string
}{
{consent.DocAdult, "1.0"},
{consent.DocTerms, h.cfg.TermsVersion},
{consent.DocPrivacy, h.cfg.PrivacyVersion},
{consent.DocDisclaimer, h.cfg.DisclaimerVersion},
} {
_ = h.consent.Record(c.UserContext(), &consent.Consent{
UserID: u.ID, DocType: item.Type, DocVersion: item.Ver,
IP: ip, UserAgent: ua,
})
}
uid := u.ID
_ = h.audit.Log(c.UserContext(), &audit.Event{
UserID: &uid, Action: "user.register",
IP: ip, UserAgent: ua,
})
tokens, err := h.auth.Generate(u.ID)
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())
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"user": fiber.Map{
"id": u.ID,
"email": u.Email,
"phone": u.Phone,
"name": u.Name,
"city": u.City,
"gender": u.Gender,
"birthdate": u.Birthdate,
},
"tokens": tokens,
})
}
type loginRequest struct {
Email string `json:"email"`
Phone string `json:"phone"`
Password string `json:"password"`
}
// Login — фикс для Safari: разрыв keep-alive после Set-Cookie
func (h *AuthHandler) Login(c *fiber.Ctx) error {
c.Set("Connection", "close")
c.Set("Cache-Control", "no-store")
var req loginRequest
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"})
}
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"})
}
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))
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
}
if u.IsBlocked {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "account blocked"})
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
}
tokens, err := h.auth.Generate(u.ID)
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())
return c.JSON(fiber.Map{
"user": u,
"tokens": tokens,
})
}
func (h *AuthHandler) Me(c *fiber.Ctx) error {
idStr, err := auth.UserID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
id, err := uuid.Parse(idStr)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "bad user id"})
}
u, err := h.users.GetByID(c.UserContext(), id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
if u == nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
}
return c.JSON(u)
}