init
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user