- 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
241 lines
7.1 KiB
Go
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)
|
|
}
|