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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user