diff --git a/README.md b/README.md index 2cf139c..db7e0bb 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ npm run dev - 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。 - 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`。 - 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。 -- 后台已使用独立登录和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。 +- 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。 - 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`。 - 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`。 diff --git a/backend/internal/modules/adminauth/dto.go b/backend/internal/modules/adminauth/dto.go index a03c04f..4eb3a9d 100644 --- a/backend/internal/modules/adminauth/dto.go +++ b/backend/internal/modules/adminauth/dto.go @@ -15,11 +15,19 @@ type AdminDTO struct { } type LoginRequest struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + CaptchaID string `json:"captcha_id" binding:"required"` + CaptchaCode string `json:"captcha_code" binding:"required"` } type LoginResult struct { Admin AdminDTO `json:"admin"` Tokens auth.TokenPair `json:"tokens"` } + +type CaptchaDTO struct { + CaptchaID string `json:"captcha_id"` + Image string `json:"image"` + ExpiresIn int64 `json:"expires_in"` +} diff --git a/backend/internal/modules/adminauth/handler.go b/backend/internal/modules/adminauth/handler.go index bf047ee..498e681 100644 --- a/backend/internal/modules/adminauth/handler.go +++ b/backend/internal/modules/adminauth/handler.go @@ -19,13 +19,22 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } +func (h *Handler) Captcha(c *gin.Context) { + item, err := h.service.Captcha() + if err != nil { + writeAdminAuthError(c, err) + return + } + response.OK(c, item) +} + func (h *Handler) Login(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "用户名和密码不能为空") + response.BadRequest(c, "用户名、密码和验证码不能为空") return } - result, err := h.service.Login(strings.TrimSpace(req.Username), req.Password) + result, err := h.service.Login(strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode)) if err != nil { writeAdminAuthError(c, err) return @@ -62,6 +71,8 @@ func writeAdminAuthError(c *gin.Context, err error) { response.ServiceUnavailable(c, "数据库未连接") case errors.Is(err, ErrInvalidCredential): response.BadRequest(c, "用户名或密码错误") + case errors.Is(err, ErrCaptchaInvalid): + response.BadRequest(c, "验证码错误或已过期") case errors.Is(err, ErrAdminDisabled): response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用") default: diff --git a/backend/internal/modules/adminauth/repository.go b/backend/internal/modules/adminauth/repository.go index d62a0ee..bfff456 100644 --- a/backend/internal/modules/adminauth/repository.go +++ b/backend/internal/modules/adminauth/repository.go @@ -1,12 +1,20 @@ package adminauth import ( + "context" + "crypto/rand" + "encoding/base64" "errors" + "fmt" + "html" + "math/big" + "strings" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/auth" + "github.com/redis/go-redis/v9" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -14,18 +22,46 @@ import ( const ( defaultAdminUsername = "admin" defaultAdminPassword = "admin123456" + captchaTTL = 3 * time.Minute ) type Repository struct { - db *gorm.DB - jwt *auth.JWTManager + db *gorm.DB + redis *redis.Client + jwt *auth.JWTManager } -func NewRepository(db *gorm.DB, jwt *auth.JWTManager) *Repository { - return &Repository{db: db, jwt: jwt} +func NewRepository(db *gorm.DB, redis *redis.Client, jwt *auth.JWTManager) *Repository { + return &Repository{db: db, redis: redis, jwt: jwt} } -func (r *Repository) Login(username string, password string) (LoginResult, error) { +func (r *Repository) Captcha() (*CaptchaDTO, error) { + if r.redis == nil { + return nil, ErrDependencyUnavailable + } + captchaID, err := randomToken(16) + if err != nil { + return nil, err + } + code, err := randomCaptchaCode(4) + if err != nil { + return nil, err + } + ctx := context.Background() + 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()), + }, nil +} + +func (r *Repository) Login(username string, password string, captchaID string, captchaCode string) (LoginResult, error) { + if err := r.verifyCaptcha(captchaID, captchaCode); err != nil { + return LoginResult{}, err + } if err := r.ensureDefaultAdmin(); err != nil { return LoginResult{}, err } @@ -54,6 +90,26 @@ func (r *Repository) Login(username string, password string) (LoginResult, error return LoginResult{Admin: toDTO(admin), Tokens: tokens}, nil } +func (r *Repository) verifyCaptcha(captchaID string, captchaCode string) error { + if r.redis == nil { + return ErrDependencyUnavailable + } + ctx := context.Background() + key := captchaKey(captchaID) + stored, err := r.redis.Get(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return ErrCaptchaInvalid + } + if err != nil { + return err + } + _ = r.redis.Del(ctx, key).Err() + if strings.ToUpper(strings.TrimSpace(captchaCode)) != stored { + return ErrCaptchaInvalid + } + return nil +} + func (r *Repository) FindByID(id uint64) (*AdminDTO, error) { var admin model.AdminUser if err := r.db.First(&admin, id).Error; err != nil { @@ -96,3 +152,39 @@ func toDTO(admin model.AdminUser) AdminDTO { LastLoginAt: admin.LastLoginAt, } } + +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/adminauth/service.go b/backend/internal/modules/adminauth/service.go index 3018d02..0866b74 100644 --- a/backend/internal/modules/adminauth/service.go +++ b/backend/internal/modules/adminauth/service.go @@ -5,6 +5,7 @@ import "errors" var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidCredential = errors.New("invalid credential") + ErrCaptchaInvalid = errors.New("captcha invalid") ErrAdminDisabled = errors.New("admin disabled") ) @@ -16,14 +17,21 @@ func NewService(repo *Repository) *Service { return &Service{repo: repo} } -func (s *Service) Login(username string, password string) (LoginResult, error) { +func (s *Service) Captcha() (*CaptchaDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Captcha() +} + +func (s *Service) Login(username string, password string, captchaID string, captchaCode string) (LoginResult, error) { if s.repo == nil { return LoginResult{}, ErrDependencyUnavailable } - if username == "" || password == "" { + if username == "" || password == "" || captchaID == "" || captchaCode == "" { return LoginResult{}, ErrInvalidCredential } - return s.repo.Login(username, password) + return s.repo.Login(username, password, captchaID, captchaCode) } func (s *Service) Me(adminID uint64) (*AdminDTO, error) { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 64ad49a..d7ddc57 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -41,7 +41,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { authHandler := auth.NewHandler(authService) var adminAuthRepo *adminauth.Repository if deps.DB != nil { - adminAuthRepo = adminauth.NewRepository(deps.DB, jwtManager) + adminAuthRepo = adminauth.NewRepository(deps.DB, deps.Redis, jwtManager) } adminAuthService := adminauth.NewService(adminAuthRepo) adminAuthHandler := adminauth.NewHandler(adminAuthService) @@ -168,6 +168,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminAuthRoutes := api.Group("/admin/auth") { + adminAuthRoutes.GET("/captcha", adminAuthHandler.Captcha) adminAuthRoutes.POST("/login", adminAuthHandler.Login) } diff --git a/docs/api.md b/docs/api.md index 6f82592..852340e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -46,6 +46,7 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `GET /api/wallet/ledger` - `GET /api/notifications` - `POST /api/notifications/{id}/read` +- `GET /api/admin/auth/captcha` - `POST /api/admin/auth/login` - `POST /api/admin/auth/logout` - `GET /api/admin/me` diff --git a/docs/business-rules.md b/docs/business-rules.md index a25a4b9..98d3b33 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -99,6 +99,8 @@ - 后台登录接口为 `/api/admin/auth/login`,前端页面为 `/admin/login`。 - 后台页面使用独立管理布局,不再混用用户端侧边栏。 +- 后台登录前必须先请求 `/api/admin/auth/captcha` 获取图形验证码。 +- 图形验证码为后端生成的 SVG,验证码答案保存在 Redis,默认 3 分钟过期,校验后立即删除。 - 后台 JWT 与普通用户 JWT 区分 `admin` 和 `user`,普通用户 Token 不能访问 `/api/admin/*`。 - 开发态首次后台登录会自动初始化默认管理员:用户名 `admin`,密码 `admin123456`。 - 默认管理员只用于本地开发;正式部署前必须改为初始化脚本、强密码和管理员密码修改流程。 diff --git a/frontend/src/api/adminAuth.ts b/frontend/src/api/adminAuth.ts index f5291c2..133b491 100644 --- a/frontend/src/api/adminAuth.ts +++ b/frontend/src/api/adminAuth.ts @@ -20,14 +20,30 @@ export interface AdminLoginData { tokens: AdminTokenPair } +export interface AdminCaptcha { + captcha_id: string + image: string + expires_in: number +} + interface ApiResponse { code: string message: string data: T } -export async function loginAdmin(username: string, password: string) { - const { data } = await apiClient.post>('/admin/auth/login', { username, password }) +export async function fetchAdminCaptcha() { + const { data } = await apiClient.get>('/admin/auth/captcha') + return data.data +} + +export async function loginAdmin(username: string, password: string, captchaId: string, captchaCode: string) { + const { data } = await apiClient.post>('/admin/auth/login', { + username, + password, + captcha_id: captchaId, + captcha_code: captchaCode, + }) return data.data } diff --git a/frontend/src/stores/adminSession.ts b/frontend/src/stores/adminSession.ts index 49aae3b..5b707bb 100644 --- a/frontend/src/stores/adminSession.ts +++ b/frontend/src/stores/adminSession.ts @@ -11,8 +11,8 @@ export const useAdminSessionStore = defineStore('adminSession', { nickname: '', }), actions: { - async login(username: string, password: string) { - const result = await loginAdmin(username, password) + async login(username: string, password: string, captchaId: string, captchaCode: string) { + const result = await loginAdmin(username, password, captchaId, captchaCode) this.applySession(result.admin, result.tokens.access_token, result.tokens.refresh_token) return result }, diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index a888fbc..98153d0 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -199,6 +199,37 @@ a { color: #52616f; } +.admin-captcha-row { + display: grid; + grid-template-columns: 1fr 132px; + gap: 10px; + width: 100%; +} + +.captcha-image-button { + display: flex; + align-items: center; + justify-content: center; + height: 44px; + border: 1px solid #dcdfe6; + border-radius: 6px; + background: #ffffff; + padding: 0; + cursor: pointer; + overflow: hidden; +} + +.captcha-image-button:disabled { + cursor: wait; + opacity: 0.7; +} + +.captcha-image-button img { + display: block; + width: 132px; + height: 44px; +} + .page { max-width: 1120px; } diff --git a/frontend/src/views/admin/AdminLoginView.vue b/frontend/src/views/admin/AdminLoginView.vue index 8fdf41c..2574a69 100644 --- a/frontend/src/views/admin/AdminLoginView.vue +++ b/frontend/src/views/admin/AdminLoginView.vue @@ -1,26 +1,45 @@