buhapp-backend/internal/handlers/auth.go
ga 8d754f9834 Sprint 1: scaffold backend (Go + Fiber + PG + Redis + MinIO)
- POST /api/v1/auth/register with mandatory consents
- POST /api/v1/auth/login
- GET  /api/v1/me (protected)
- GET  /api/v1/legal/{terms,privacy,disclaimer}
- Migrations for users, consent_log, audit_log
- bcrypt + JWT (access + refresh)
- Docker Compose stack
2026-08-20 15:41:05 +00:00

241 lines
7.1 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"
)
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}$`)
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"})
}
// validate required consents
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",
})
}
// normalize
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 or db error"})
}
ip := c.IP()
ua := c.Get("User-Agent")
// log consents
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,
})
}
// audit
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 {
// MVP: поиск по телефону пока не реализован — добавим позже
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"})
}
if u == nil {
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)
}