diff --git a/backend/.env.prod.example b/backend/.env.prod.example index f495ab7..b6b3459 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -37,7 +37,7 @@ LOG_DIR=/app/logs LOG_ENABLE_CONSOLE=true LOG_ENABLE_FILE=true -# MinIO 容器初始化变量,同时供后端对象存储使用。 +# MinIO 容器初始化变量,同时供后端对象存储使用;使用内置 MinIO 时,STORAGE_* 密钥必须和 MINIO_ROOT_* 保持一致。 MINIO_ROOT_USER=change-minio-user MINIO_ROOT_PASSWORD=change-minio-password STORAGE_ENDPOINT=http://minio:9000 diff --git a/backend/internal/captcha/captcha.go b/backend/internal/captcha/captcha.go new file mode 100644 index 0000000..0daaea3 --- /dev/null +++ b/backend/internal/captcha/captcha.go @@ -0,0 +1,113 @@ +package captcha + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "html" + "math/big" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +var ( + ErrDependencyUnavailable = errors.New("captcha dependency unavailable") + ErrInvalid = errors.New("captcha invalid") +) + +type Item struct { + CaptchaID string `json:"captcha_id"` + Image string `json:"image"` + ExpiresIn int64 `json:"expires_in"` +} + +func Generate(ctx context.Context, redisClient *redis.Client, namespace string, ttl time.Duration) (*Item, error) { + if redisClient == nil { + return nil, ErrDependencyUnavailable + } + captchaID, err := randomToken(16) + if err != nil { + return nil, err + } + code, err := randomCaptchaCode(4) + if err != nil { + return nil, err + } + if err := redisClient.Set(ctx, key(namespace, captchaID), strings.ToUpper(code), ttl).Err(); err != nil { + return nil, err + } + return &Item{ + CaptchaID: captchaID, + Image: imageDataURL(code), + ExpiresIn: int64(ttl.Seconds()), + }, nil +} + +func Verify(ctx context.Context, redisClient *redis.Client, namespace string, captchaID string, captchaCode string) error { + if redisClient == nil { + return ErrDependencyUnavailable + } + captchaID = strings.TrimSpace(captchaID) + captchaCode = strings.TrimSpace(captchaCode) + if captchaID == "" || captchaCode == "" { + return ErrInvalid + } + + redisKey := key(namespace, captchaID) + stored, err := redisClient.Get(ctx, redisKey).Result() + if errors.Is(err, redis.Nil) { + return ErrInvalid + } + if err != nil { + return err + } + _ = redisClient.Del(ctx, redisKey).Err() + if strings.ToUpper(captchaCode) != stored { + return ErrInvalid + } + return nil +} + +func key(namespace string, id string) string { + namespace = strings.Trim(strings.ToLower(strings.TrimSpace(namespace)), ":") + if namespace == "" { + namespace = "default" + } + return "captcha:" + namespace + ":" + id +} + +func randomToken(length int) (string, error) { + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func randomCaptchaCode(length int) (string, error) { + const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" + result := make([]byte, length) + for i := range result { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) + if err != nil { + return "", err + } + result[i] = alphabet[n.Int64()] + } + return string(result), nil +} + +func imageDataURL(code string) string { + safeCode := html.EscapeString(strings.ToUpper(code)) + svg := fmt.Sprintf(` + + + +%s +`, safeCode) + return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)) +} diff --git a/backend/internal/modules/adminauth/repository.go b/backend/internal/modules/adminauth/repository.go index 799b09c..63ce7d4 100644 --- a/backend/internal/modules/adminauth/repository.go +++ b/backend/internal/modules/adminauth/repository.go @@ -2,16 +2,13 @@ package adminauth import ( "context" - "crypto/rand" - "encoding/base64" "encoding/json" "errors" "fmt" - "html" - "math/big" "strings" "time" + "hfb_sys/backend/internal/captcha" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/auth" @@ -38,24 +35,17 @@ func NewRepository(db *gorm.DB, redis *redis.Client, jwt *auth.JWTManager) *Repo } func (r *Repository) Captcha(ctx context.Context) (*CaptchaDTO, error) { - if r.redis == nil { + item, err := captcha.Generate(ctx, r.redis, "admin", captchaTTL) + if errors.Is(err, captcha.ErrDependencyUnavailable) { return nil, ErrDependencyUnavailable } - captchaID, err := randomToken(16) if err != nil { return nil, err } - code, err := randomCaptchaCode(4) - if err != nil { - return nil, err - } - if err := r.redis.Set(ctx, captchaKey(captchaID), strings.ToUpper(code), captchaTTL).Err(); err != nil { - return nil, err - } return &CaptchaDTO{ - CaptchaID: captchaID, - Image: captchaImageDataURL(code), - ExpiresIn: int64(captchaTTL.Seconds()), + CaptchaID: item.CaptchaID, + Image: item.Image, + ExpiresIn: item.ExpiresIn, }, nil } @@ -120,22 +110,14 @@ func (r *Repository) RevokeTokens(ctx context.Context, adminID uint64) error { } func (r *Repository) verifyCaptcha(ctx context.Context, captchaID string, captchaCode string) error { - if r.redis == nil { + err := captcha.Verify(ctx, r.redis, "admin", captchaID, captchaCode) + if errors.Is(err, captcha.ErrDependencyUnavailable) { return ErrDependencyUnavailable } - key := captchaKey(captchaID) - stored, err := r.redis.Get(ctx, key).Result() - if errors.Is(err, redis.Nil) { + if errors.Is(err, captcha.ErrInvalid) { return ErrCaptchaInvalid } - if err != nil { - return err - } - _ = r.redis.Del(ctx, key).Err() - if strings.ToUpper(strings.TrimSpace(captchaCode)) != stored { - return ErrCaptchaInvalid - } - return nil + return err } func (r *Repository) FindByID(ctx context.Context, id uint64) (*AdminDTO, error) { @@ -273,39 +255,3 @@ func loginKeyPart(value string) string { } return value } - -func captchaKey(id string) string { - return "admin:captcha:" + id -} - -func randomToken(length int) (string, error) { - buf := make([]byte, length) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(buf), nil -} - -func randomCaptchaCode(length int) (string, error) { - const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" - result := make([]byte, length) - for i := range result { - n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) - if err != nil { - return "", err - } - result[i] = alphabet[n.Int64()] - } - return string(result), nil -} - -func captchaImageDataURL(code string) string { - safeCode := html.EscapeString(strings.ToUpper(code)) - svg := fmt.Sprintf(` - - - -%s -`, safeCode) - return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)) -} diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go index a68c91d..54d3c23 100644 --- a/backend/internal/modules/auth/handler.go +++ b/backend/internal/modules/auth/handler.go @@ -15,7 +15,9 @@ type Handler struct { } type SendSMSRequest struct { - Phone string `json:"phone" binding:"required"` + Phone string `json:"phone" binding:"required"` + CaptchaID string `json:"captcha_id" binding:"required"` + CaptchaCode string `json:"captcha_code" binding:"required"` } type LoginRequest struct { @@ -31,6 +33,15 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } +func (h *Handler) Captcha(c *gin.Context) { + item, err := h.service.Captcha(c.Request.Context()) + if err != nil { + writeAuthError(c, err) + return + } + response.OK(c, item) +} + // SendSMS 发送短信验证码 // @Summary 发送短信验证码 // @Description 发送登录验证码到指定手机号 @@ -45,10 +56,10 @@ func NewHandler(service *Service) *Handler { func (h *Handler) SendSMS(c *gin.Context) { var req SendSMSRequest if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "手机号不能为空") + response.BadRequest(c, "手机号和图形验证码不能为空") return } - if err := h.service.SendSMSCode(c.Request.Context(), strings.TrimSpace(req.Phone)); err != nil { + if err := h.service.SendSMSCode(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode)); err != nil { writeAuthError(c, err) return } @@ -125,6 +136,8 @@ func writeAuthError(c *gin.Context, err error) { response.ServiceUnavailable(c, "数据库或 Redis 未连接") case errors.Is(err, ErrInvalidPhone): response.BadRequest(c, "手机号格式不正确") + case errors.Is(err, ErrCaptchaInvalid): + response.BadRequest(c, "图形验证码错误或已过期") case errors.Is(err, ErrCodeRateLimited): response.Error(c, http.StatusTooManyRequests, "rate_limited", "验证码发送过于频繁") case errors.Is(err, ErrSMSSendFailed): diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index 17f696f..dbed589 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -9,6 +9,7 @@ import ( "regexp" "time" + "hfb_sys/backend/internal/captcha" smsprovider "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/model" @@ -19,6 +20,7 @@ import ( var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidPhone = errors.New("invalid phone") + ErrCaptchaInvalid = errors.New("captcha invalid") ErrCodeRateLimited = errors.New("sms code rate limited") ErrCodeInvalid = errors.New("sms code invalid") ErrSMSSendFailed = errors.New("sms send failed") @@ -29,6 +31,7 @@ const ( smsLoginCooldown = 60 * time.Second smsLoginHourlyLimit = 5 smsLoginHourlyWindow = time.Hour + smsCaptchaTTL = 3 * time.Minute ) type Service struct { @@ -48,7 +51,18 @@ func NewService(users *UserRepository, redis *redis.Client, jwt *JWTManager, sms return &Service{users: users, redis: redis, jwt: jwt, sms: sms, log: log} } -func (s *Service) SendSMSCode(ctx context.Context, phone string) error { +func (s *Service) Captcha(ctx context.Context) (*captcha.Item, error) { + if s.redis == nil { + return nil, ErrDependencyUnavailable + } + item, err := captcha.Generate(ctx, s.redis, "auth:sms", smsCaptchaTTL) + if errors.Is(err, captcha.ErrDependencyUnavailable) { + return nil, ErrDependencyUnavailable + } + return item, err +} + +func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID string, captchaCode string) error { if s.redis == nil { return ErrDependencyUnavailable } @@ -74,6 +88,16 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string) error { return ErrCodeRateLimited } + if err := captcha.Verify(ctx, s.redis, "auth:sms", captchaID, captchaCode); err != nil { + if errors.Is(err, captcha.ErrDependencyUnavailable) { + return ErrDependencyUnavailable + } + if errors.Is(err, captcha.ErrInvalid) { + return ErrCaptchaInvalid + } + return err + } + code, err := randomDigits(6) if err != nil { return err diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 9f031aa..a6786a1 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -299,6 +299,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { authRoutes := api.Group("/auth") { + authRoutes.GET("/captcha", authHandler.Captcha) authRoutes.POST("/sms/send", authHandler.SendSMS) authRoutes.POST("/sms/login", authHandler.Login) authRoutes.POST("/refresh", authHandler.Refresh) diff --git a/deploy/README.md b/deploy/README.md index d63ae1b..2740680 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -47,6 +47,7 @@ STORAGE_SECRET_ACCESS_KEY 脚本会自动完成: - 检查 `backend/.env` 是否仍指向 `127.0.0.1`、`localhost` 或占位值。 +- 使用内置 MinIO 时,检查 `STORAGE_ACCESS_KEY_ID` / `STORAGE_SECRET_ACCESS_KEY` 是否和 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 一致。 - 构建并启动生产容器。 - 通过 Caddy 自动申请或续签 HTTPS 证书。 - 等待 MySQL、Redis、MinIO 就绪。 diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index 8ab0169..e591f61 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -25,11 +25,24 @@ export interface LoginData { tokens: TokenPair } -export async function sendSmsCode(phone: string) { +export interface AuthCaptcha { + captcha_id: string + image: string + expires_in: number +} + +export async function fetchAuthCaptcha() { + const { data } = await apiClient.get>('/auth/captcha') + return data.data +} + +export async function sendSmsCode(phone: string, captchaId: string, captchaCode: string) { const { data } = await apiClient.post>( '/auth/sms/send', { phone, + captcha_id: captchaId, + captcha_code: captchaCode, } ) return data.data diff --git a/frontend/src/features/auth/views/LoginView.vue b/frontend/src/features/auth/views/LoginView.vue index 671b2ab..b803ce8 100644 --- a/frontend/src/features/auth/views/LoginView.vue +++ b/frontend/src/features/auth/views/LoginView.vue @@ -1,20 +1,23 @@