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(``, 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(``, 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 @@