This commit is contained in:
2026-06-15 16:53:25 +03:30
commit 390ef96e7e
34 changed files with 4463 additions and 0 deletions
+160
View File
@@ -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})
}
+90
View File
@@ -0,0 +1,90 @@
package auth
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type ctxKey string
const userIDKey ctxKey = "uid"
// JWT صدور و اعتبارسنجی توکن stateless.
type JWT struct {
secret []byte
ttl time.Duration
}
func NewJWT(secret string, ttl time.Duration) *JWT {
return &JWT{secret: []byte(secret), ttl: ttl}
}
// Issue توکن برای کاربر صادر می‌کند. ability مثل "user" یا "admin".
func (j *JWT) Issue(userID int64, ability string) (string, error) {
claims := jwt.MapClaims{
"sub": strconv.FormatInt(userID, 10),
"abl": ability,
"exp": time.Now().Add(j.ttl).Unix(),
"iat": time.Now().Unix(),
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return tok.SignedString(j.secret)
}
func (j *JWT) parse(tokenStr string) (int64, error) {
tok, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return j.secret, nil
})
if err != nil || !tok.Valid {
return 0, errors.New("invalid token")
}
claims, ok := tok.Claims.(jwt.MapClaims)
if !ok {
return 0, errors.New("invalid claims")
}
sub, _ := claims["sub"].(string)
id, err := strconv.ParseInt(sub, 10, 64)
if err != nil {
return 0, errors.New("invalid sub")
}
return id, nil
}
// Verify توکن را اعتبارسنجی کرده و شناسه کاربر را برمی‌گرداند (برای WebSocket).
func (j *JWT) Verify(token string) (int64, error) {
return j.parse(token)
}
// Middleware احراز هویت Bearer JWT.
func (j *JWT) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
tokenStr := strings.TrimPrefix(h, "Bearer ")
if tokenStr == h || tokenStr == "" {
http.Error(w, `{"message":"unauthorized"}`, http.StatusUnauthorized)
return
}
id, err := j.parse(tokenStr)
if err != nil {
http.Error(w, `{"message":"unauthorized"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// UserID شناسه کاربر را از context (پس از Middleware) برمی‌گرداند.
func UserID(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(userIDKey).(int64)
return id, ok
}
+61
View File
@@ -0,0 +1,61 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
// Kavenegar کلاینت ارسال OTP از طریق verify/lookup (معادل KavenegarService.php).
type Kavenegar struct {
APIKey string
Template string // مثل "loginotp"
client *http.Client
}
func NewKavenegar(apiKey, template string) *Kavenegar {
return &Kavenegar{
APIKey: apiKey,
Template: template,
client: &http.Client{Timeout: 5 * time.Second},
}
}
// SendVerify کد را با تمپلیت verify ارسال می‌کند.
func (k *Kavenegar) SendVerify(ctx context.Context, mobile, token string) error {
if k.APIKey == "" {
return fmt.Errorf("KAVE_API_KEY is null")
}
endpoint := fmt.Sprintf("https://api.kavenegar.com/v1/%s/verify/lookup.json", k.APIKey)
q := url.Values{}
q.Set("receptor", mobile)
q.Set("token", token)
q.Set("template", k.Template)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+q.Encode(), nil)
if err != nil {
return err
}
resp, err := k.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var out struct {
Return struct {
Status int `json:"status"`
Message string `json:"message"`
} `json:"return"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return fmt.Errorf("kavenegar decode: %w", err)
}
if out.Return.Status != 200 {
return fmt.Errorf("kavenegar failed: status=%d msg=%s", out.Return.Status, out.Return.Message)
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package auth
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"math/big"
"time"
)
// OTPStore مدیریت کدهای OTP در جدول otp_tokens.
type OTPStore struct {
db *sql.DB
ttl time.Duration
}
func NewOTPStore(db *sql.DB, ttl time.Duration) *OTPStore {
return &OTPStore{db: db, ttl: ttl}
}
// Generate یک کد ۵ رقمی امن می‌سازد (مطابق rand(10000,99999) در Laravel).
func Generate() string {
n, err := rand.Int(rand.Reader, big.NewInt(90000))
if err != nil {
return "12345" // عملاً رخ نمی‌دهد
}
return big.NewInt(10000 + n.Int64()).String()
}
// Create کد را برای کاربر ذخیره می‌کند.
func (s *OTPStore) Create(ctx context.Context, userID int64, token string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO otp_tokens (user_id, token) VALUES (?, ?)`, userID, token)
return err
}
var ErrInvalidOTP = errors.New("token not valid")
// Verify اعتبار کد را در بازه ttl بررسی و پس از مصرف حذف می‌کند.
func (s *OTPStore) Verify(ctx context.Context, userID int64, token string) error {
cutoff := time.Now().Add(-s.ttl).UTC().Format("2006-01-02 15:04:05")
var id int64
err := s.db.QueryRowContext(ctx,
`SELECT id FROM otp_tokens
WHERE user_id = ? AND token = ? AND created_at > ?
ORDER BY id DESC LIMIT 1`, userID, token, cutoff).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return ErrInvalidOTP
}
if err != nil {
return err
}
// مصرف شد ⇒ حذف
_, err = s.db.ExecContext(ctx, `DELETE FROM otp_tokens WHERE id = ?`, id)
return err
}