Files
back-hokm/internal/auth/handler.go
T
2026-06-15 16:53:25 +03:30

161 lines
4.2 KiB
Go

package auth
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"regexp"
"time"
"hakemsho/internal/httpx"
"hakemsho/internal/user"
)
// Handler endpointهای احراز هویت را فراهم می‌کند.
type Handler struct {
users *user.Repo
otp *OTPStore
kave *Kavenegar
jwt *JWT
throttle *httpx.Throttle
adminMobile string
adminOTP string
}
func NewHandler(users *user.Repo, otp *OTPStore, kave *Kavenegar, jwt *JWT, adminMobile, adminOTP string) *Handler {
return &Handler{
users: users,
otp: otp,
kave: kave,
jwt: jwt,
throttle: httpx.NewThrottle(3, time.Minute), // معادل throttle:3,1
adminMobile: adminMobile,
adminOTP: adminOTP,
}
}
// mobileRe اعتبارسنجی ساده شماره موبایل ایران.
var mobileRe = regexp.MustCompile(`^09\d{9}$`)
type loginOTPReq struct {
Mobile string `json:"mobile"`
FCMToken string `json:"fcm_token"`
}
// LoginOTP — POST /api/auth/login-otp
func (h *Handler) LoginOTP(w http.ResponseWriter, r *http.Request) {
var req loginOTPReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
if !mobileRe.MatchString(req.Mobile) {
httpx.Error(w, http.StatusUnprocessableEntity, "invalid mobile")
return
}
// throttle بر اساس موبایل: حداکثر ۳ بار در دقیقه
if !h.throttle.Allow(req.Mobile) {
httpx.Error(w, http.StatusTooManyRequests, "too many requests")
return
}
ctx := r.Context()
u, err := h.users.FindOrCreate(ctx, req.Mobile)
if err != nil {
slog.Error("find or create user", "err", err)
httpx.Error(w, http.StatusInternalServerError, "server error")
return
}
// شماره ادمین کد ثابت دارد و SMS نمی‌گیرد
if req.Mobile != h.adminMobile {
code := Generate()
if err := h.otp.Create(ctx, u.ID, code); err != nil {
slog.Error("create otp", "err", err)
httpx.Error(w, http.StatusInternalServerError, "server error")
return
}
// ارسال SMS به‌صورت fire-and-forget (بدون صف جانبی، مطابق پلن مینیموم)
go func(mobile, code string) {
bg, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.kave.SendVerify(bg, mobile, code); err != nil {
slog.Error("kavenegar send", "mobile", mobile, "err", err)
}
}(req.Mobile, code)
}
httpx.JSON(w, http.StatusOK, map[string]string{"message": "otp sent"})
}
type checkOTPReq struct {
Mobile string `json:"mobile"`
Token string `json:"token"`
}
// CheckOTP — POST /api/auth/check-otp
func (h *Handler) CheckOTP(w http.ResponseWriter, r *http.Request) {
var req checkOTPReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
if req.Mobile == "" || req.Token == "" {
httpx.Error(w, http.StatusUnprocessableEntity, "mobile and token required")
return
}
ctx := r.Context()
u, err := h.users.FindByMobile(ctx, req.Mobile)
if errors.Is(err, user.ErrNotFound) {
httpx.Error(w, http.StatusNotFound, "user not found")
return
}
if err != nil {
httpx.Error(w, http.StatusInternalServerError, "server error")
return
}
// راه ادمین برای تست بدون SMS
adminBypass := h.adminMobile != "" && req.Mobile == h.adminMobile && req.Token == h.adminOTP
if !adminBypass {
if err := h.otp.Verify(ctx, u.ID, req.Token); err != nil {
httpx.Error(w, http.StatusBadRequest, "token not valid")
return
}
}
ability := "user"
if u.IsAdmin {
ability = "admin"
}
token, err := h.jwt.Issue(u.ID, ability)
if err != nil {
httpx.Error(w, http.StatusInternalServerError, "server error")
return
}
httpx.JSON(w, http.StatusOK, map[string]any{
"user": u,
"token": token,
})
}
// Me — GET /api/me (نیازمند JWT)
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
id, ok := UserID(r.Context())
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
u, err := h.users.FindByID(r.Context(), id)
if err != nil {
httpx.Error(w, http.StatusNotFound, "user not found")
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"user": u})
}