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" ) 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 { 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"}) } 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"` } func (h *AuthHandler) Login(c *fiber.Ctx) error { 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"}) } uid := u.ID _ = h.audit.Log(c.UserContext(), &audit.Event{ UserID: &uid, Action: "user.login", IP: c.IP(), UserAgent: c.Get("User-Agent"), }) 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) }