Safari блокирует fetch после POST с Set-Cookie из-за keep-alive/HTTP2. - Login/Register: Connection: close, Cache-Control: no-store - Cookie: SameSite=None (кросс-поддомен buhapp → api.buhapp)
264 lines
8.5 KiB
Go
264 lines
8.5 KiB
Go
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)
|
||
}
|