加固后台管理安全
This commit is contained in:
@@ -14,6 +14,12 @@ REDIS_DB=0
|
|||||||
|
|
||||||
JWT_SECRET=change-me
|
JWT_SECRET=change-me
|
||||||
|
|
||||||
|
# 首次启动且管理员表为空时,按以下变量创建首个超级管理员。
|
||||||
|
# 本地开发可自行填写,生产环境请使用随机强密码,创建后建议从 .env 移除。
|
||||||
|
ADMIN_BOOTSTRAP_USERNAME=
|
||||||
|
ADMIN_BOOTSTRAP_PASSWORD=
|
||||||
|
ADMIN_BOOTSTRAP_NICKNAME=超级管理员
|
||||||
|
|
||||||
# API 限流:默认开启,每个 IP 每分钟 300 次。
|
# API 限流:默认开启,每个 IP 每分钟 300 次。
|
||||||
RATE_LIMIT_ENABLED=true
|
RATE_LIMIT_ENABLED=true
|
||||||
RATE_LIMIT_REQUESTS_PER_MINUTE=300
|
RATE_LIMIT_REQUESTS_PER_MINUTE=300
|
||||||
@@ -49,3 +55,9 @@ REALNAME_CLOUDMARKET_APPCODE=
|
|||||||
# 用于加密存储支付商户配置中的敏感信息(sign_key、notify_key)
|
# 用于加密存储支付商户配置中的敏感信息(sign_key、notify_key)
|
||||||
# 生成方式:openssl rand -hex 16
|
# 生成方式:openssl rand -hex 16
|
||||||
PAYMENT_CONFIG_ENCRYPTION_KEY=
|
PAYMENT_CONFIG_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# 开放导入接口签名密钥;留空时 /api/open/listing-uploads 不可用。
|
||||||
|
# 请求需携带 X-HFB-Timestamp 和 X-HFB-Signature。
|
||||||
|
EXTERNAL_UPLOAD_SECRET=
|
||||||
|
# 可选:逗号分隔的 IP 或 CIDR 白名单,例如 127.0.0.1,10.0.0.0/8。
|
||||||
|
EXTERNAL_UPLOAD_ALLOWED_IPS=
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ REDIS_DB=0
|
|||||||
|
|
||||||
JWT_SECRET=change-to-a-long-random-secret
|
JWT_SECRET=change-to-a-long-random-secret
|
||||||
|
|
||||||
|
# 首次启动且管理员表为空时创建首个超级管理员;生产密码至少 12 位且包含字母和数字。
|
||||||
|
# 创建成功后建议从 .env 移除,后续管理员通过后台维护。
|
||||||
|
ADMIN_BOOTSTRAP_USERNAME=change-admin-username
|
||||||
|
ADMIN_BOOTSTRAP_PASSWORD=change-admin-password-123
|
||||||
|
ADMIN_BOOTSTRAP_NICKNAME=超级管理员
|
||||||
|
|
||||||
# API 限流:默认开启,每个 IP 每分钟 300 次。
|
# API 限流:默认开启,每个 IP 每分钟 300 次。
|
||||||
RATE_LIMIT_ENABLED=true
|
RATE_LIMIT_ENABLED=true
|
||||||
RATE_LIMIT_REQUESTS_PER_MINUTE=300
|
RATE_LIMIT_REQUESTS_PER_MINUTE=300
|
||||||
@@ -55,3 +61,8 @@ REALNAME_CLOUDMARKET_APPCODE=
|
|||||||
# 生成方式:openssl rand -hex 16
|
# 生成方式:openssl rand -hex 16
|
||||||
# 警告:此密钥一旦设置不要更改,否则已有配置无法解密
|
# 警告:此密钥一旦设置不要更改,否则已有配置无法解密
|
||||||
PAYMENT_CONFIG_ENCRYPTION_KEY=change-to-32-byte-encryption-key
|
PAYMENT_CONFIG_ENCRYPTION_KEY=change-to-32-byte-encryption-key
|
||||||
|
|
||||||
|
# 开放导入接口签名密钥,生产环境必填;生成方式:openssl rand -hex 32
|
||||||
|
EXTERNAL_UPLOAD_SECRET=change-to-a-long-random-upload-secret
|
||||||
|
# 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。
|
||||||
|
EXTERNAL_UPLOAD_ALLOWED_IPS=
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/database"
|
"hfb_sys/backend/internal/database"
|
||||||
"hfb_sys/backend/internal/jobs/ordertimeout"
|
"hfb_sys/backend/internal/jobs/ordertimeout"
|
||||||
"hfb_sys/backend/internal/logging"
|
"hfb_sys/backend/internal/logging"
|
||||||
|
"hfb_sys/backend/internal/modules/adminauth"
|
||||||
"hfb_sys/backend/internal/router"
|
"hfb_sys/backend/internal/router"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -45,6 +46,9 @@ func main() {
|
|||||||
defer func() {
|
defer func() {
|
||||||
_ = logger.Sync()
|
_ = logger.Sync()
|
||||||
}()
|
}()
|
||||||
|
if err := cfg.ValidateProductionSecurity(); err != nil {
|
||||||
|
logger.Fatal("production security config invalid", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
var deps router.Dependencies
|
var deps router.Dependencies
|
||||||
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level)
|
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level)
|
||||||
@@ -52,6 +56,14 @@ func main() {
|
|||||||
logger.Warn("mysql unavailable; database-backed APIs will return 503", zap.Error(err))
|
logger.Warn("mysql unavailable; database-backed APIs will return 503", zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
deps.DB = db
|
deps.DB = db
|
||||||
|
if err := adminauth.BootstrapAdmin(context.Background(), db, adminauth.BootstrapConfig{
|
||||||
|
AppEnv: cfg.AppEnv,
|
||||||
|
Username: cfg.BootstrapAdminUsername,
|
||||||
|
Password: cfg.BootstrapAdminPassword,
|
||||||
|
Nickname: cfg.BootstrapAdminNickname,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Fatal("admin bootstrap failed", zap.Error(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
redisClient, err := database.OpenRedis(context.Background(), database.RedisConfig{
|
redisClient, err := database.OpenRedis(context.Background(), database.RedisConfig{
|
||||||
Addr: cfg.RedisAddr,
|
Addr: cfg.RedisAddr,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -13,6 +15,12 @@ type Config struct {
|
|||||||
RedisPassword string
|
RedisPassword string
|
||||||
RedisDB int
|
RedisDB int
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
PaymentConfigEncryptionKey string
|
||||||
|
ExternalUploadSecret string
|
||||||
|
ExternalUploadAllowedIPs []string
|
||||||
|
BootstrapAdminUsername string
|
||||||
|
BootstrapAdminPassword string
|
||||||
|
BootstrapAdminNickname string
|
||||||
Storage StorageConfig
|
Storage StorageConfig
|
||||||
SMS SMSConfig
|
SMS SMSConfig
|
||||||
Realname RealnameConfig
|
Realname RealnameConfig
|
||||||
@@ -63,6 +71,12 @@ func Load() Config {
|
|||||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||||
RedisDB: getEnvInt("REDIS_DB", 0),
|
RedisDB: getEnvInt("REDIS_DB", 0),
|
||||||
JWTSecret: getEnv("JWT_SECRET", "change-me"),
|
JWTSecret: getEnv("JWT_SECRET", "change-me"),
|
||||||
|
PaymentConfigEncryptionKey: getEnv("PAYMENT_CONFIG_ENCRYPTION_KEY", ""),
|
||||||
|
ExternalUploadSecret: getEnv("EXTERNAL_UPLOAD_SECRET", ""),
|
||||||
|
ExternalUploadAllowedIPs: getEnvList("EXTERNAL_UPLOAD_ALLOWED_IPS"),
|
||||||
|
BootstrapAdminUsername: getEnv("ADMIN_BOOTSTRAP_USERNAME", ""),
|
||||||
|
BootstrapAdminPassword: getEnv("ADMIN_BOOTSTRAP_PASSWORD", ""),
|
||||||
|
BootstrapAdminNickname: getEnv("ADMIN_BOOTSTRAP_NICKNAME", "超级管理员"),
|
||||||
Storage: StorageConfig{
|
Storage: StorageConfig{
|
||||||
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"),
|
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"),
|
||||||
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
|
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
|
||||||
@@ -95,6 +109,26 @@ func Load() Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c Config) ValidateProductionSecurity() error {
|
||||||
|
if strings.ToLower(strings.TrimSpace(c.AppEnv)) != "production" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 {
|
||||||
|
return errors.New("JWT_SECRET must be a non-default random value of at least 32 bytes in production")
|
||||||
|
}
|
||||||
|
keyLen := len([]byte(c.PaymentConfigEncryptionKey))
|
||||||
|
if isPlaceholder(c.PaymentConfigEncryptionKey) || (keyLen != 16 && keyLen != 24 && keyLen != 32) {
|
||||||
|
return errors.New("PAYMENT_CONFIG_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.ExternalUploadSecret) == "" || isPlaceholder(c.ExternalUploadSecret) {
|
||||||
|
return errors.New("EXTERNAL_UPLOAD_SECRET is required in production")
|
||||||
|
}
|
||||||
|
if c.BootstrapAdminPassword != "" && isPlaceholder(c.BootstrapAdminPassword) {
|
||||||
|
return errors.New("ADMIN_BOOTSTRAP_PASSWORD must not use the example placeholder in production")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
value := os.Getenv(key)
|
value := os.Getenv(key)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -126,3 +160,24 @@ func getEnvBool(key string, fallback bool) bool {
|
|||||||
}
|
}
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvList(key string) []string {
|
||||||
|
raw := os.Getenv(key)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
values := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
value := strings.TrimSpace(part)
|
||||||
|
if value != "" {
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPlaceholder(value string) bool {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
return normalized == "change-me" || strings.HasPrefix(normalized, "change-") || strings.Contains(normalized, "change-to-")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/modules/auth"
|
"hfb_sys/backend/internal/modules/auth"
|
||||||
@@ -14,14 +16,30 @@ const (
|
|||||||
ContextPhone = "phone"
|
ContextPhone = "phone"
|
||||||
ContextAdminID = "admin_id"
|
ContextAdminID = "admin_id"
|
||||||
ContextUsername = "username"
|
ContextUsername = "username"
|
||||||
|
ContextPasswordMustChange = "password_must_change"
|
||||||
|
AdminAccessCookieName = "hfb_admin_access"
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractToken(c *gin.Context) string {
|
type AdminTokenContext struct {
|
||||||
|
Username string
|
||||||
|
PasswordMustChange bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error)
|
||||||
|
|
||||||
|
func extractBearerToken(c *gin.Context) string {
|
||||||
header := c.GetHeader("Authorization")
|
header := c.GetHeader("Authorization")
|
||||||
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||||
if tokenText != "" && tokenText != header {
|
if tokenText != "" && tokenText != header {
|
||||||
return tokenText
|
return tokenText
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToken(c *gin.Context) string {
|
||||||
|
if tokenText := extractBearerToken(c); tokenText != "" {
|
||||||
|
return tokenText
|
||||||
|
}
|
||||||
return c.Query("token")
|
return c.Query("token")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +65,14 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminAuth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
tokenText := extractToken(c)
|
tokenText := extractBearerToken(c)
|
||||||
|
if tokenText == "" {
|
||||||
|
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
|
||||||
|
tokenText = strings.TrimSpace(cookieToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
if tokenText == "" {
|
if tokenText == "" {
|
||||||
response.Unauthorized(c, "缺少后台访问令牌")
|
response.Unauthorized(c, "缺少后台访问令牌")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
@@ -62,9 +85,44 @@ func AdminAuth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
username := claims.Phone
|
||||||
|
passwordMustChange := false
|
||||||
|
if validate != nil {
|
||||||
|
tokenContext, err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "后台访问令牌无效或已过期")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username = tokenContext.Username
|
||||||
|
passwordMustChange = tokenContext.PasswordMustChange
|
||||||
|
}
|
||||||
|
|
||||||
c.Set(ContextAdminID, claims.UserID)
|
c.Set(ContextAdminID, claims.UserID)
|
||||||
c.Set(ContextUsername, claims.Phone)
|
c.Set(ContextUsername, username)
|
||||||
|
c.Set(ContextPasswordMustChange, passwordMustChange)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequireAdminPasswordChanged() gin.HandlerFunc {
|
||||||
|
allowed := map[string]bool{
|
||||||
|
"/api/admin/me": true,
|
||||||
|
"/api/admin/auth/logout": true,
|
||||||
|
"/api/admin/admin-users/me/password": true,
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
value, ok := c.Get(ContextPasswordMustChange)
|
||||||
|
if !ok {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mustChange, ok := value.(bool)
|
||||||
|
if !ok || !mustChange || allowed[c.FullPath()] {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Error(c, http.StatusForbidden, "password_must_change", "请先修改初始密码")
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,36 +16,54 @@ import (
|
|||||||
// 超级管理员(拥有 super_admin 角色的管理员)自动放行。
|
// 超级管理员(拥有 super_admin 角色的管理员)自动放行。
|
||||||
func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc {
|
func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if checkPermission(c, permCode, rdb) {
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequirePermissionIf(permCode string, rdb *redis.Client, predicate func(*gin.Context) bool) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if predicate == nil || !predicate(c) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if checkPermission(c, permCode, rdb) {
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
|
||||||
value, ok := c.Get(ContextAdminID)
|
value, ok := c.Get(ContextAdminID)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "缺少管理员上下文")
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
adminID, ok := value.(uint64)
|
adminID, ok := value.(uint64)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "管理员上下文无效")
|
response.Unauthorized(c, "管理员上下文无效")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
codes, err := getPermCodes(c, rdb, adminID)
|
codes, err := getPermCodes(c, rdb, adminID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "perm_check_failed", "权限校验服务暂时不可用")
|
response.Error(c, http.StatusInternalServerError, "perm_check_failed", "权限校验服务暂时不可用")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, code := range codes {
|
for _, code := range codes {
|
||||||
if code == permCode || code == "*" {
|
if code == permCode || code == "*" {
|
||||||
c.Next()
|
return true
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限")
|
response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
}
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) {
|
func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
type rateLimitBucket struct {
|
type rateLimitBucket struct {
|
||||||
@@ -22,7 +24,14 @@ type rateLimiter struct {
|
|||||||
buckets map[string]rateLimitBucket
|
buckets map[string]rateLimitBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
func RateLimitPerMinute(limit int) gin.HandlerFunc {
|
type redisRateLimiter struct {
|
||||||
|
redis *redis.Client
|
||||||
|
fallback *rateLimiter
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -33,24 +42,57 @@ func RateLimitPerMinute(limit int) gin.HandlerFunc {
|
|||||||
window: time.Minute,
|
window: time.Minute,
|
||||||
buckets: make(map[string]rateLimitBucket),
|
buckets: make(map[string]rateLimitBucket),
|
||||||
}
|
}
|
||||||
|
if rdb != nil {
|
||||||
|
return (&redisRateLimiter{
|
||||||
|
redis: rdb,
|
||||||
|
fallback: limiter,
|
||||||
|
limit: limit,
|
||||||
|
window: time.Minute,
|
||||||
|
}).handle
|
||||||
|
}
|
||||||
return limiter.handle
|
return limiter.handle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *redisRateLimiter) handle(c *gin.Context) {
|
||||||
|
now := time.Now()
|
||||||
|
key := c.ClientIP()
|
||||||
|
allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
|
||||||
|
if err != nil {
|
||||||
|
allowed, resetAt = l.fallback.allow(key, now)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
writeRateLimited(c, now, resetAt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
func (l *rateLimiter) handle(c *gin.Context) {
|
func (l *rateLimiter) handle(c *gin.Context) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
key := c.ClientIP()
|
key := c.ClientIP()
|
||||||
allowed, resetAt := l.allow(key, now)
|
allowed, resetAt := l.allow(key, now)
|
||||||
if !allowed {
|
if !allowed {
|
||||||
c.Header("Retry-After", retryAfterSeconds(now, resetAt))
|
writeRateLimited(c, now, resetAt)
|
||||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
|
||||||
"code": "rate_limited",
|
|
||||||
"message": "请求过于频繁,请稍后再试",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) {
|
||||||
|
windowSeconds := int64(l.window / time.Second)
|
||||||
|
windowID := now.Unix() / windowSeconds
|
||||||
|
redisKey := "rate_limit:" + key + ":" + strconv.FormatInt(windowID, 10)
|
||||||
|
count, err := l.redis.Incr(ctx, redisKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return false, time.Time{}, err
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
_ = l.redis.Expire(ctx, redisKey, 2*l.window).Err()
|
||||||
|
}
|
||||||
|
resetAt := time.Unix((windowID+1)*windowSeconds, 0)
|
||||||
|
return count <= int64(l.limit), resetAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (l *rateLimiter) allow(key string, now time.Time) (bool, time.Time) {
|
func (l *rateLimiter) allow(key string, now time.Time) (bool, time.Time) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -81,3 +123,11 @@ func retryAfterSeconds(now time.Time, resetAt time.Time) string {
|
|||||||
}
|
}
|
||||||
return strconv.Itoa(seconds)
|
return strconv.Itoa(seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeRateLimited(c *gin.Context, now time.Time, resetAt time.Time) {
|
||||||
|
c.Header("Retry-After", retryAfterSeconds(now, resetAt))
|
||||||
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||||
|
"code": "rate_limited",
|
||||||
|
"message": "请求过于频繁,请稍后再试",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type AdminUser struct {
|
|||||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||||
SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"`
|
SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"`
|
||||||
|
TokenVersion int64 `gorm:"not null;default:1" json:"-"`
|
||||||
|
PasswordMustChange bool `gorm:"not null;default:false" json:"password_must_change"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package adminauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BootstrapConfig struct {
|
||||||
|
AppEnv string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Nickname string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BootstrapAdmin 在管理员表为空时按部署配置创建首个超级管理员。
|
||||||
|
func BootstrapAdmin(ctx context.Context, db *gorm.DB, cfg BootstrapConfig) error {
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.WithContext(ctx).Model(&model.AdminUser{}).Count(&count).Error; err != nil {
|
||||||
|
if isAdminTableMissing(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(cfg.Username)
|
||||||
|
password := cfg.Password
|
||||||
|
if username == "" || password == "" {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") {
|
||||||
|
return errors.New("admin bootstrap credentials are required when no admin exists in production")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") && !bootstrapPasswordStrong(password) {
|
||||||
|
return errors.New("ADMIN_BOOTSTRAP_PASSWORD must be at least 12 chars and include letters and digits in production")
|
||||||
|
}
|
||||||
|
nickname := strings.TrimSpace(cfg.Nickname)
|
||||||
|
if nickname == "" {
|
||||||
|
nickname = username
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
admin := model.AdminUser{
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Nickname: nickname,
|
||||||
|
Status: "active",
|
||||||
|
TokenVersion: 1,
|
||||||
|
PasswordMustChange: true,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&admin).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var superAdminRole model.Role
|
||||||
|
if err := tx.Where("code = ?", "super_admin").First(&superAdminRole).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&model.AdminUserRole{
|
||||||
|
AdminID: admin.ID,
|
||||||
|
RoleID: superAdminRole.ID,
|
||||||
|
}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootstrapPasswordStrong(value string) bool {
|
||||||
|
if len([]rune(value)) < 12 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasLetter := false
|
||||||
|
hasDigit := false
|
||||||
|
for _, r := range value {
|
||||||
|
if unicode.IsLetter(r) {
|
||||||
|
hasLetter = true
|
||||||
|
}
|
||||||
|
if unicode.IsDigit(r) {
|
||||||
|
hasDigit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasLetter && hasDigit
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAdminTableMissing(err error) bool {
|
||||||
|
message := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(message, "admin_users") && (strings.Contains(message, "doesn't exist") || strings.Contains(message, "no such table"))
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ type AdminDTO struct {
|
|||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
SupportStatus string `json:"support_status"`
|
SupportStatus string `json:"support_status"`
|
||||||
|
PasswordMustChange bool `json:"password_must_change"`
|
||||||
Roles []RoleDTO `json:"roles"`
|
Roles []RoleDTO `json:"roles"`
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
|
|||||||
@@ -2,15 +2,23 @@ package adminauth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/middleware"
|
"hfb_sys/backend/internal/middleware"
|
||||||
|
"hfb_sys/backend/internal/modules/auth"
|
||||||
"hfb_sys/backend/pkg/response"
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
adminAccessCookieName = middleware.AdminAccessCookieName
|
||||||
|
adminRefreshCookieName = "hfb_admin_refresh"
|
||||||
|
adminRefreshCookieMaxAge = 14 * 24 * 60 * 60
|
||||||
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *Service
|
service *Service
|
||||||
}
|
}
|
||||||
@@ -34,12 +42,13 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
response.BadRequest(c, "用户名、密码和验证码不能为空")
|
response.BadRequest(c, "用户名、密码和验证码不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := h.service.Login(c.Request.Context(), strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode))
|
result, err := h.service.Login(c.Request.Context(), strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode), c.ClientIP())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAdminAuthError(c, err)
|
writeAdminAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, result)
|
setAdminAuthCookies(c, result.Tokens)
|
||||||
|
response.OK(c, gin.H{"admin": result.Admin})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Me(c *gin.Context) {
|
func (h *Handler) Me(c *gin.Context) {
|
||||||
@@ -62,6 +71,16 @@ func (h *Handler) Me(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Logout(c *gin.Context) {
|
func (h *Handler) Logout(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.Logout(c.Request.Context(), adminID); err != nil {
|
||||||
|
writeAdminAuthError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearAdminAuthCookies(c)
|
||||||
response.OK(c, gin.H{"logged_out": true})
|
response.OK(c, gin.H{"logged_out": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,21 +112,57 @@ func currentAdminID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AdminRefreshRequest struct {
|
type AdminRefreshRequest struct {
|
||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Refresh(c *gin.Context) {
|
func (h *Handler) Refresh(c *gin.Context) {
|
||||||
var req AdminRefreshRequest
|
var req AdminRefreshRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
response.BadRequest(c, "refresh_token 格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshToken := strings.TrimSpace(req.RefreshToken)
|
||||||
|
if refreshToken == "" {
|
||||||
|
if cookieValue, err := c.Cookie(adminRefreshCookieName); err == nil {
|
||||||
|
refreshToken = strings.TrimSpace(cookieValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if refreshToken == "" {
|
||||||
response.BadRequest(c, "refresh_token 不能为空")
|
response.BadRequest(c, "refresh_token 不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokens, err := h.service.Refresh(c.Request.Context(), req.RefreshToken)
|
tokens, err := h.service.Refresh(c.Request.Context(), refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAdminAuthError(c, err)
|
writeAdminAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, tokens)
|
setAdminAuthCookies(c, *tokens)
|
||||||
|
response.OK(c, gin.H{"refreshed": true, "expires_in": tokens.ExpiresInSeconds})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAdminAuthCookies(c *gin.Context, tokens auth.TokenPair) {
|
||||||
|
secure := isHTTPSRequest(c)
|
||||||
|
httpOnly := true
|
||||||
|
sameSite := http.SameSiteStrictMode
|
||||||
|
c.SetSameSite(sameSite)
|
||||||
|
c.SetCookie(adminAccessCookieName, tokens.AccessToken, int(tokens.ExpiresInSeconds), "/api/admin", "", secure, httpOnly)
|
||||||
|
c.SetCookie(adminRefreshCookieName, tokens.RefreshToken, adminRefreshCookieMaxAge, "/api/admin/auth/refresh", "", secure, httpOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAdminAuthCookies(c *gin.Context) {
|
||||||
|
secure := isHTTPSRequest(c)
|
||||||
|
c.SetSameSite(http.SameSiteStrictMode)
|
||||||
|
c.SetCookie(adminAccessCookieName, "", -1, "/api/admin", "", secure, true)
|
||||||
|
c.SetCookie(adminRefreshCookieName, "", -1, "/api/admin/auth/refresh", "", secure, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHTTPSRequest(c *gin.Context) bool {
|
||||||
|
if c.Request.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeAdminAuthError(c *gin.Context, err error) {
|
func writeAdminAuthError(c *gin.Context, err error) {
|
||||||
@@ -120,6 +175,8 @@ func writeAdminAuthError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "用户名或密码错误")
|
response.BadRequest(c, "用户名或密码错误")
|
||||||
case errors.Is(err, ErrCaptchaInvalid):
|
case errors.Is(err, ErrCaptchaInvalid):
|
||||||
response.BadRequest(c, "验证码错误或已过期")
|
response.BadRequest(c, "验证码错误或已过期")
|
||||||
|
case errors.Is(err, ErrLoginLocked):
|
||||||
|
response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请稍后再试")
|
||||||
case errors.Is(err, ErrAdminDisabled):
|
case errors.Is(err, ErrAdminDisabled):
|
||||||
response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用")
|
response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultAdminUsername = "admin"
|
|
||||||
defaultAdminPassword = "admin123456"
|
|
||||||
captchaTTL = 3 * time.Minute
|
captchaTTL = 3 * time.Minute
|
||||||
|
loginFailureTTL = 15 * time.Minute
|
||||||
|
loginLockTTL = 15 * time.Minute
|
||||||
|
loginMaxFailureCount = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
@@ -58,16 +59,17 @@ func (r *Repository) Captcha(ctx context.Context) (*CaptchaDTO, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string) (LoginResult, error) {
|
func (r *Repository) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) {
|
||||||
if err := r.verifyCaptcha(ctx, captchaID, captchaCode); err != nil {
|
if err := r.verifyCaptcha(ctx, captchaID, captchaCode); err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
if err := r.ensureDefaultAdmin(ctx); err != nil {
|
if err := r.ensureLoginNotLocked(ctx, username, clientIP); err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
var admin model.AdminUser
|
var admin model.AdminUser
|
||||||
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil {
|
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
r.recordLoginFailure(ctx, username, clientIP)
|
||||||
return LoginResult{}, ErrInvalidCredential
|
return LoginResult{}, ErrInvalidCredential
|
||||||
}
|
}
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
@@ -76,14 +78,19 @@ func (r *Repository) Login(ctx context.Context, username string, password string
|
|||||||
return LoginResult{}, ErrAdminDisabled
|
return LoginResult{}, ErrAdminDisabled
|
||||||
}
|
}
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
|
||||||
|
r.recordLoginFailure(ctx, username, clientIP)
|
||||||
return LoginResult{}, ErrInvalidCredential
|
return LoginResult{}, ErrInvalidCredential
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
admin.LastLoginAt = &now
|
admin.LastLoginAt = &now
|
||||||
|
if admin.TokenVersion <= 0 {
|
||||||
|
admin.TokenVersion = 1
|
||||||
|
}
|
||||||
if err := r.db.WithContext(ctx).Save(&admin).Error; err != nil {
|
if err := r.db.WithContext(ctx).Save(&admin).Error; err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
tokens, err := r.jwt.GenerateSubjectPair(admin.ID, admin.Username, "admin")
|
r.clearLoginFailures(ctx, username, clientIP)
|
||||||
|
tokens, err := r.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
@@ -92,6 +99,26 @@ func (r *Repository) Login(ctx context.Context, username string, password string
|
|||||||
return LoginResult{Admin: dto, Tokens: tokens}, nil
|
return LoginResult{Admin: dto, Tokens: tokens}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.AdminUser, error) {
|
||||||
|
var admin model.AdminUser
|
||||||
|
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if admin.Status != "active" {
|
||||||
|
return nil, ErrAdminDisabled
|
||||||
|
}
|
||||||
|
if admin.TokenVersion <= 0 || admin.TokenVersion != tokenVersion {
|
||||||
|
return nil, ErrInvalidRefreshToken
|
||||||
|
}
|
||||||
|
return &admin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) RevokeTokens(ctx context.Context, adminID uint64) error {
|
||||||
|
return r.db.WithContext(ctx).Model(&model.AdminUser{}).
|
||||||
|
Where("id = ?", adminID).
|
||||||
|
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) verifyCaptcha(ctx context.Context, captchaID string, captchaCode string) error {
|
func (r *Repository) verifyCaptcha(ctx context.Context, captchaID string, captchaCode string) error {
|
||||||
if r.redis == nil {
|
if r.redis == nil {
|
||||||
return ErrDependencyUnavailable
|
return ErrDependencyUnavailable
|
||||||
@@ -136,40 +163,6 @@ func (r *Repository) UpdateSupportStatus(ctx context.Context, adminID uint64, st
|
|||||||
Update("support_status", status).Error
|
Update("support_status", status).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ensureDefaultAdmin(ctx context.Context) error {
|
|
||||||
var count int64
|
|
||||||
if err := r.db.WithContext(ctx).Model(&model.AdminUser{}).Count(&count).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(defaultAdminPassword), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
admin := model.AdminUser{
|
|
||||||
Username: defaultAdminUsername,
|
|
||||||
PasswordHash: string(hash),
|
|
||||||
Nickname: "超级管理员",
|
|
||||||
Status: "active",
|
|
||||||
}
|
|
||||||
if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动关联 super_admin 角色
|
|
||||||
var superAdminRole model.Role
|
|
||||||
if err := r.db.WithContext(ctx).Where("code = ?", "super_admin").First(&superAdminRole).Error; err == nil {
|
|
||||||
r.db.WithContext(ctx).Create(&model.AdminUserRole{
|
|
||||||
AdminID: admin.ID,
|
|
||||||
RoleID: superAdminRole.ID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toDTO(admin model.AdminUser) AdminDTO {
|
func toDTO(admin model.AdminUser) AdminDTO {
|
||||||
return AdminDTO{
|
return AdminDTO{
|
||||||
ID: admin.ID,
|
ID: admin.ID,
|
||||||
@@ -177,6 +170,7 @@ func toDTO(admin model.AdminUser) AdminDTO {
|
|||||||
Nickname: admin.Nickname,
|
Nickname: admin.Nickname,
|
||||||
Status: admin.Status,
|
Status: admin.Status,
|
||||||
SupportStatus: admin.SupportStatus,
|
SupportStatus: admin.SupportStatus,
|
||||||
|
PasswordMustChange: admin.PasswordMustChange,
|
||||||
LastLoginAt: admin.LastLoginAt,
|
LastLoginAt: admin.LastLoginAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +218,62 @@ func cachePermissions(ctx context.Context, r *Repository, adminID uint64, permCo
|
|||||||
r.redis.Set(ctx, key, string(raw), 2*time.Hour)
|
r.redis.Set(ctx, key, string(raw), 2*time.Hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ensureLoginNotLocked(ctx context.Context, username string, clientIP string) error {
|
||||||
|
if r.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
locked, err := r.redis.Exists(ctx, loginLockKey(username, clientIP)).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if locked > 0 {
|
||||||
|
return ErrLoginLocked
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) recordLoginFailure(ctx context.Context, username string, clientIP string) {
|
||||||
|
if r.redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := loginFailureKey(username, clientIP)
|
||||||
|
count, err := r.redis.Incr(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
_ = r.redis.Expire(ctx, key, loginFailureTTL).Err()
|
||||||
|
}
|
||||||
|
if count >= loginMaxFailureCount {
|
||||||
|
_ = r.redis.Set(ctx, loginLockKey(username, clientIP), "1", loginLockTTL).Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) clearLoginFailures(ctx context.Context, username string, clientIP string) {
|
||||||
|
if r.redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = r.redis.Del(ctx, loginFailureKey(username, clientIP), loginLockKey(username, clientIP)).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginFailureKey(username string, clientIP string) string {
|
||||||
|
return "admin:login:fail:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginLockKey(username string, clientIP string) string {
|
||||||
|
return "admin:login:lock:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginKeyPart(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
value = strings.ReplaceAll(value, ":", "_")
|
||||||
|
value = strings.ReplaceAll(value, "/", "_")
|
||||||
|
if value == "" {
|
||||||
|
return "_"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func captchaKey(id string) string {
|
func captchaKey(id string) string {
|
||||||
return "admin:captcha:" + id
|
return "admin:captcha:" + id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ var (
|
|||||||
ErrCaptchaInvalid = errors.New("captcha invalid")
|
ErrCaptchaInvalid = errors.New("captcha invalid")
|
||||||
ErrAdminDisabled = errors.New("admin disabled")
|
ErrAdminDisabled = errors.New("admin disabled")
|
||||||
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||||
|
ErrLoginLocked = errors.New("login locked")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -32,7 +33,17 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.Token
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrInvalidRefreshToken
|
return nil, ErrInvalidRefreshToken
|
||||||
}
|
}
|
||||||
pair, err := s.jwt.GenerateSubjectPair(claims.UserID, claims.Phone, "admin")
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
admin, err := s.repo.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrAdminDisabled) {
|
||||||
|
return nil, ErrAdminDisabled
|
||||||
|
}
|
||||||
|
return nil, ErrInvalidRefreshToken
|
||||||
|
}
|
||||||
|
pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -46,14 +57,14 @@ func (s *Service) Captcha(ctx context.Context) (*CaptchaDTO, error) {
|
|||||||
return s.repo.Captcha(ctx)
|
return s.repo.Captcha(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string) (LoginResult, error) {
|
func (s *Service) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return LoginResult{}, ErrDependencyUnavailable
|
return LoginResult{}, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
if username == "" || password == "" || captchaID == "" || captchaCode == "" {
|
if username == "" || password == "" || captchaID == "" || captchaCode == "" {
|
||||||
return LoginResult{}, ErrInvalidCredential
|
return LoginResult{}, ErrInvalidCredential
|
||||||
}
|
}
|
||||||
return s.repo.Login(ctx, username, password, captchaID, captchaCode)
|
return s.repo.Login(ctx, username, password, captchaID, captchaCode, clientIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Me(ctx context.Context, adminID uint64) (*AdminDTO, error) {
|
func (s *Service) Me(ctx context.Context, adminID uint64) (*AdminDTO, error) {
|
||||||
@@ -69,3 +80,10 @@ func (s *Service) UpdateSupportStatus(ctx context.Context, adminID uint64, statu
|
|||||||
}
|
}
|
||||||
return s.repo.UpdateSupportStatus(ctx, adminID, status)
|
return s.repo.UpdateSupportStatus(ctx, adminID, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Logout(ctx context.Context, adminID uint64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.RevokeTokens(ctx, adminID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,3 +43,7 @@ type ChangePasswordRequest struct {
|
|||||||
OldPassword string `json:"old_password" binding:"required"`
|
OldPassword string `json:"old_password" binding:"required"`
|
||||||
NewPassword string `json:"new_password" binding:"required"`
|
NewPassword string `json:"new_password" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResetPasswordRequest struct {
|
||||||
|
NewPassword string `json:"new_password" binding:"required"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,8 +106,13 @@ func (h *Handler) AssignRoles(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ChangePassword(c *gin.Context) {
|
func (h *Handler) ChangePassword(c *gin.Context) {
|
||||||
id, ok := parseID(c)
|
h.ChangeOwnPassword(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ChangeOwnPassword(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req ChangePasswordRequest
|
var req ChangePasswordRequest
|
||||||
@@ -115,13 +120,39 @@ func (h *Handler) ChangePassword(c *gin.Context) {
|
|||||||
response.BadRequest(c, "密码不能为空")
|
response.BadRequest(c, "密码不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.service.ChangePassword(c.Request.Context(), id, req); err != nil {
|
if err := h.service.ChangeOwnPassword(c.Request.Context(), adminID, req); err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"updated": true})
|
response.OK(c, gin.H{"updated": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ResetPassword(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ResetPasswordRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "密码不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.ResetPassword(c.Request.Context(), id, req); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"updated": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||||
|
value, ok := c.Get(middleware.ContextAdminID)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
adminID, ok := value.(uint64)
|
||||||
|
return adminID, ok
|
||||||
|
}
|
||||||
|
|
||||||
func parseID(c *gin.Context) (uint64, bool) {
|
func parseID(c *gin.Context) (uint64, bool) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -141,6 +172,8 @@ func writeError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "不能删除最后一个超级管理员")
|
response.BadRequest(c, "不能删除最后一个超级管理员")
|
||||||
case errors.Is(err, ErrWrongPassword):
|
case errors.Is(err, ErrWrongPassword):
|
||||||
response.BadRequest(c, "原密码错误")
|
response.BadRequest(c, "原密码错误")
|
||||||
|
case errors.Is(err, ErrWeakPassword):
|
||||||
|
response.BadRequest(c, "密码至少 8 位且需包含字母和数字")
|
||||||
case IsNotFound(err):
|
case IsNotFound(err):
|
||||||
response.Error(c, http.StatusNotFound, "not_found", "管理员不存在")
|
response.Error(c, http.StatusNotFound, "not_found", "管理员不存在")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ func (r *Repository) Create(ctx context.Context, req CreateAdminRequest) (*Admin
|
|||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
Nickname: req.Nickname,
|
Nickname: req.Nickname,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
|
TokenVersion: 1,
|
||||||
|
PasswordMustChange: true,
|
||||||
}
|
}
|
||||||
if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil {
|
if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -102,6 +104,12 @@ func (r *Repository) Update(ctx context.Context, id uint64, req UpdateAdminReque
|
|||||||
if err := db.Save(&admin).Error; err != nil {
|
if err := db.Save(&admin).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if req.Status != "" {
|
||||||
|
if err := r.bumpTokenVersion(db, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.invalidatePermCache(ctx, id)
|
||||||
|
}
|
||||||
return r.FindByID(ctx, id)
|
return r.FindByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +158,7 @@ func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs []
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return r.bumpTokenVersion(tx, adminID)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -159,7 +167,7 @@ func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs []
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ChangePassword(ctx context.Context, id uint64, oldPwd, newPwd string) error {
|
func (r *Repository) ChangeOwnPassword(ctx context.Context, id uint64, oldPwd, newPwd string) error {
|
||||||
var admin model.AdminUser
|
var admin model.AdminUser
|
||||||
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
|
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -171,7 +179,35 @@ func (r *Repository) ChangePassword(ctx context.Context, id uint64, oldPwd, newP
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return r.db.WithContext(ctx).Model(&admin).Update("password_hash", string(hash)).Error
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&admin).Updates(map[string]any{
|
||||||
|
"password_hash": string(hash),
|
||||||
|
"password_must_change": false,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.bumpTokenVersion(tx, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ResetPassword(ctx context.Context, id uint64, newPwd string) error {
|
||||||
|
var admin model.AdminUser
|
||||||
|
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&admin).Updates(map[string]any{
|
||||||
|
"password_hash": string(hash),
|
||||||
|
"password_must_change": true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.bumpTokenVersion(tx, id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) GetPermissionCodes(ctx context.Context, adminID uint64) ([]string, error) {
|
func (r *Repository) GetPermissionCodes(ctx context.Context, adminID uint64) ([]string, error) {
|
||||||
@@ -223,6 +259,12 @@ func (r *Repository) invalidatePermCache(ctx context.Context, adminID uint64) {
|
|||||||
r.redis.Del(ctx, permCacheKey(adminID))
|
r.redis.Del(ctx, permCacheKey(adminID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) bumpTokenVersion(db *gorm.DB, adminID uint64) error {
|
||||||
|
return db.Model(&model.AdminUser{}).
|
||||||
|
Where("id = ?", adminID).
|
||||||
|
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
||||||
|
}
|
||||||
|
|
||||||
func permCacheKey(adminID uint64) string {
|
func permCacheKey(adminID uint64) string {
|
||||||
return fmt.Sprintf("admin:perms:%d", adminID)
|
return fmt.Sprintf("admin:perms:%d", adminID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ package adminmgr
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrDependencyUnavailable = errors.New("dependency unavailable")
|
var (
|
||||||
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
|
ErrWeakPassword = errors.New("weak password")
|
||||||
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *Repository
|
repo *Repository
|
||||||
@@ -39,8 +43,8 @@ func (s *Service) Create(ctx context.Context, req CreateAdminRequest) (*AdminUse
|
|||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
if len(req.Password) < 6 {
|
if !passwordStrongEnough(req.Password) {
|
||||||
return nil, errors.New("password too short")
|
return nil, ErrWeakPassword
|
||||||
}
|
}
|
||||||
return s.repo.Create(ctx, req)
|
return s.repo.Create(ctx, req)
|
||||||
}
|
}
|
||||||
@@ -66,12 +70,39 @@ func (s *Service) AssignRoles(ctx context.Context, adminID uint64, req AssignRol
|
|||||||
return s.repo.AssignRoles(ctx, adminID, req.RoleIDs)
|
return s.repo.AssignRoles(ctx, adminID, req.RoleIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ChangePassword(ctx context.Context, id uint64, req ChangePasswordRequest) error {
|
func (s *Service) ChangeOwnPassword(ctx context.Context, adminID uint64, req ChangePasswordRequest) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return ErrDependencyUnavailable
|
return ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
if len(req.NewPassword) < 6 {
|
if !passwordStrongEnough(req.NewPassword) {
|
||||||
return errors.New("new password too short")
|
return ErrWeakPassword
|
||||||
}
|
}
|
||||||
return s.repo.ChangePassword(ctx, id, req.OldPassword, req.NewPassword)
|
return s.repo.ChangeOwnPassword(ctx, adminID, req.OldPassword, req.NewPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ResetPassword(ctx context.Context, id uint64, req ResetPasswordRequest) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if !passwordStrongEnough(req.NewPassword) {
|
||||||
|
return ErrWeakPassword
|
||||||
|
}
|
||||||
|
return s.repo.ResetPassword(ctx, id, req.NewPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func passwordStrongEnough(value string) bool {
|
||||||
|
if len([]rune(value)) < 8 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasLetter := false
|
||||||
|
hasDigit := false
|
||||||
|
for _, r := range value {
|
||||||
|
if unicode.IsLetter(r) {
|
||||||
|
hasLetter = true
|
||||||
|
}
|
||||||
|
if unicode.IsDigit(r) {
|
||||||
|
hasDigit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasLetter && hasDigit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,22 @@ package adminrole
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
redis *redis.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB) *Repository {
|
func NewRepository(db *gorm.DB, redis *redis.Client) *Repository {
|
||||||
return &Repository{db: db}
|
return &Repository{db: db, redis: redis}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) List(ctx context.Context) ([]RoleDTO, error) {
|
func (r *Repository) List(ctx context.Context) ([]RoleDTO, error) {
|
||||||
@@ -108,15 +111,27 @@ func (r *Repository) Delete(ctx context.Context, id uint64) error {
|
|||||||
if role.Code == "super_admin" {
|
if role.Code == "super_admin" {
|
||||||
return ErrProtectedRole
|
return ErrProtectedRole
|
||||||
}
|
}
|
||||||
return db.Transaction(func(tx *gorm.DB) error {
|
adminIDs, err := r.adminIDsByRole(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("role_id = ?", id).Delete(&model.RolePermission{}).Error; err != nil {
|
if err := tx.Where("role_id = ?", id).Delete(&model.RolePermission{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Where("role_id = ?", id).Delete(&model.AdminUserRole{}).Error; err != nil {
|
if err := tx.Where("role_id = ?", id).Delete(&model.AdminUserRole{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Delete(&model.Role{}, id).Error
|
if err := tx.Delete(&model.Role{}, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.bumpAdminTokenVersions(tx, adminIDs)
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.invalidateAdminPermCaches(ctx, adminIDs)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) AssignPermissions(ctx context.Context, roleID uint64, permIDs []uint64) error {
|
func (r *Repository) AssignPermissions(ctx context.Context, roleID uint64, permIDs []uint64) error {
|
||||||
@@ -125,7 +140,11 @@ func (r *Repository) AssignPermissions(ctx context.Context, roleID uint64, permI
|
|||||||
if err := db.First(&role, roleID).Error; err != nil {
|
if err := db.First(&role, roleID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return db.Transaction(func(tx *gorm.DB) error {
|
adminIDs, err := r.adminIDsByRole(ctx, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = db.Transaction(func(tx *gorm.DB) error {
|
||||||
if role.Code == "super_admin" {
|
if role.Code == "super_admin" {
|
||||||
var allPermIDs []uint64
|
var allPermIDs []uint64
|
||||||
if err := tx.Model(&model.Permission{}).Pluck("id", &allPermIDs).Error; err != nil {
|
if err := tx.Model(&model.Permission{}).Pluck("id", &allPermIDs).Error; err != nil {
|
||||||
@@ -142,8 +161,13 @@ func (r *Repository) AssignPermissions(ctx context.Context, roleID uint64, permI
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return r.bumpAdminTokenVersions(tx, adminIDs)
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.invalidateAdminPermCaches(ctx, adminIDs)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListPermissions(ctx context.Context) ([]PermissionDTO, error) {
|
func (r *Repository) ListPermissions(ctx context.Context) ([]PermissionDTO, error) {
|
||||||
@@ -186,6 +210,34 @@ func (r *Repository) getRolePermissions(ctx context.Context, roleID uint64) ([]P
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) adminIDsByRole(ctx context.Context, roleID uint64) ([]uint64, error) {
|
||||||
|
var ids []uint64
|
||||||
|
err := r.db.WithContext(ctx).Model(&model.AdminUserRole{}).
|
||||||
|
Where("role_id = ?", roleID).
|
||||||
|
Pluck("admin_user_id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) bumpAdminTokenVersions(tx *gorm.DB, adminIDs []uint64) error {
|
||||||
|
if len(adminIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tx.Model(&model.AdminUser{}).
|
||||||
|
Where("id IN ?", adminIDs).
|
||||||
|
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) invalidateAdminPermCaches(ctx context.Context, adminIDs []uint64) {
|
||||||
|
if r.redis == nil || len(adminIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(adminIDs))
|
||||||
|
for _, id := range adminIDs {
|
||||||
|
keys = append(keys, fmt.Sprintf("admin:perms:%d", id))
|
||||||
|
}
|
||||||
|
_ = r.redis.Del(ctx, keys...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
var ErrProtectedRole = errors.New("protected role")
|
var ErrProtectedRole = errors.New("protected role")
|
||||||
|
|
||||||
func IsNotFound(err error) bool {
|
func IsNotFound(err error) bool {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Claims struct {
|
|||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
TokenType string `json:"typ"`
|
TokenType string `json:"typ"`
|
||||||
SubjectType string `json:"sub_type"`
|
SubjectType string `json:"sub_type"`
|
||||||
|
TokenVersion int64 `json:"ver,omitempty"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +51,15 @@ func (m *JWTManager) GeneratePair(userID uint64, phone string) (TokenPair, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *JWTManager) GenerateSubjectPair(userID uint64, subject string, subjectType string) (TokenPair, error) {
|
func (m *JWTManager) GenerateSubjectPair(userID uint64, subject string, subjectType string) (TokenPair, error) {
|
||||||
accessToken, err := m.generate(userID, subject, subjectType, tokenTypeAccess, m.accessTTL)
|
return m.GenerateSubjectPairWithVersion(userID, subject, subjectType, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) GenerateSubjectPairWithVersion(userID uint64, subject string, subjectType string, tokenVersion int64) (TokenPair, error) {
|
||||||
|
accessToken, err := m.generate(userID, subject, subjectType, tokenTypeAccess, tokenVersion, m.accessTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
return TokenPair{}, err
|
||||||
}
|
}
|
||||||
refreshToken, err := m.generate(userID, subject, subjectType, tokenTypeRefresh, m.refreshTTL)
|
refreshToken, err := m.generate(userID, subject, subjectType, tokenTypeRefresh, tokenVersion, m.refreshTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
return TokenPair{}, err
|
||||||
}
|
}
|
||||||
@@ -90,13 +95,14 @@ func (m *JWTManager) ParseSubject(tokenText, expectedType string, expectedSubjec
|
|||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *JWTManager) generate(userID uint64, subject string, subjectType string, tokenType string, ttl time.Duration) (string, error) {
|
func (m *JWTManager) generate(userID uint64, subject string, subjectType string, tokenType string, tokenVersion int64, ttl time.Duration) (string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Phone: subject,
|
Phone: subject,
|
||||||
TokenType: tokenType,
|
TokenType: tokenType,
|
||||||
SubjectType: subjectType,
|
SubjectType: subjectType,
|
||||||
|
TokenVersion: tokenVersion,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
|||||||
@@ -7,10 +7,22 @@ import (
|
|||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *Service
|
service *Service
|
||||||
storage *filemodule.Storage
|
storage *filemodule.Storage
|
||||||
|
externalUploadSecret string
|
||||||
|
externalUploadAllowedIPs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxExternalUploadBodyBytes = 256 * 1024
|
const maxExternalUploadBodyBytes = 256 * 1024
|
||||||
|
|
||||||
func NewHandler(service *Service, storage *filemodule.Storage) *Handler {
|
type HandlerOptions struct {
|
||||||
return &Handler{service: service, storage: storage}
|
ExternalUploadSecret string
|
||||||
|
ExternalUploadAllowedIPs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service, storage *filemodule.Storage, opts ...HandlerOptions) *Handler {
|
||||||
|
handler := &Handler{service: service, storage: storage}
|
||||||
|
if len(opts) > 0 {
|
||||||
|
handler.externalUploadSecret = opts[0].ExternalUploadSecret
|
||||||
|
handler.externalUploadAllowedIPs = opts[0].ExternalUploadAllowedIPs
|
||||||
|
}
|
||||||
|
return handler
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
package listing
|
package listing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/pkg/response"
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
@@ -17,6 +25,10 @@ func (h *Handler) ImportExternalUpload(c *gin.Context) {
|
|||||||
response.BadRequest(c, "上传内容过大或读取失败")
|
response.BadRequest(c, "上传内容过大或读取失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := h.verifyExternalUpload(c, raw); err != nil {
|
||||||
|
writeExternalUploadAuthError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
var req ExternalUploadRequest
|
var req ExternalUploadRequest
|
||||||
if err := json.Unmarshal(raw, &req); err != nil {
|
if err := json.Unmarshal(raw, &req); err != nil {
|
||||||
response.BadRequest(c, "上传 JSON 格式不正确")
|
response.BadRequest(c, "上传 JSON 格式不正确")
|
||||||
@@ -34,6 +46,82 @@ func (h *Handler) ImportExternalUpload(c *gin.Context) {
|
|||||||
response.Created(c, result)
|
response.Created(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errExternalUploadSecretMissing = errors.New("external upload secret missing")
|
||||||
|
errExternalUploadForbiddenIP = errors.New("external upload forbidden ip")
|
||||||
|
errExternalUploadTimestamp = errors.New("external upload timestamp invalid")
|
||||||
|
errExternalUploadSignature = errors.New("external upload signature invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
const externalUploadMaxClockSkew = 5 * time.Minute
|
||||||
|
|
||||||
|
func (h *Handler) verifyExternalUpload(c *gin.Context, raw []byte) error {
|
||||||
|
secret := strings.TrimSpace(h.externalUploadSecret)
|
||||||
|
if secret == "" {
|
||||||
|
return errExternalUploadSecretMissing
|
||||||
|
}
|
||||||
|
if !externalUploadIPAllowed(c.ClientIP(), h.externalUploadAllowedIPs) {
|
||||||
|
return errExternalUploadForbiddenIP
|
||||||
|
}
|
||||||
|
timestamp := strings.TrimSpace(c.GetHeader("X-HFB-Timestamp"))
|
||||||
|
signature := strings.TrimSpace(c.GetHeader("X-HFB-Signature"))
|
||||||
|
if timestamp == "" || signature == "" {
|
||||||
|
return errExternalUploadSignature
|
||||||
|
}
|
||||||
|
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errExternalUploadTimestamp
|
||||||
|
}
|
||||||
|
requestTime := time.Unix(ts, 0)
|
||||||
|
if time.Since(requestTime) > externalUploadMaxClockSkew || time.Until(requestTime) > externalUploadMaxClockSkew {
|
||||||
|
return errExternalUploadTimestamp
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(timestamp))
|
||||||
|
mac.Write([]byte("."))
|
||||||
|
mac.Write(raw)
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
if !hmac.Equal([]byte(strings.ToLower(signature)), []byte(expected)) {
|
||||||
|
return errExternalUploadSignature
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalUploadIPAllowed(clientIP string, allowed []string) bool {
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
parsedIP := net.ParseIP(clientIP)
|
||||||
|
for _, raw := range allowed {
|
||||||
|
item := strings.TrimSpace(raw)
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if parsedIP != nil {
|
||||||
|
if _, network, err := net.ParseCIDR(item); err == nil && network.Contains(parsedIP) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item == clientIP {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExternalUploadAuthError(c *gin.Context, err error) {
|
||||||
|
switch err {
|
||||||
|
case errExternalUploadSecretMissing:
|
||||||
|
response.ServiceUnavailable(c, "开放导入签名密钥未配置")
|
||||||
|
case errExternalUploadForbiddenIP:
|
||||||
|
response.Error(c, http.StatusForbidden, "forbidden", "来源 IP 不允许访问")
|
||||||
|
case errExternalUploadTimestamp:
|
||||||
|
response.Error(c, http.StatusUnauthorized, "invalid_timestamp", "请求时间戳无效")
|
||||||
|
default:
|
||||||
|
response.Error(c, http.StatusUnauthorized, "invalid_signature", "请求签名无效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) DefaultUploadScreenshot(c *gin.Context) {
|
func (h *Handler) DefaultUploadScreenshot(c *gin.Context) {
|
||||||
c.Header("Content-Type", "image/svg+xml; charset=utf-8")
|
c.Header("Content-Type", "image/svg+xml; charset=utf-8")
|
||||||
c.Header("Cache-Control", "public, max-age=86400")
|
c.Header("Cache-Control", "public, max-age=86400")
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package listing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifyExternalUploadSignature(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
body := []byte(`{"data":{"loginMethod":"QQ账号密码"}}`)
|
||||||
|
secret := "test-upload-secret"
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
req := httptest.NewRequest("POST", "/api/open/listing-uploads", nil)
|
||||||
|
req.Header.Set("X-HFB-Timestamp", strconvFormatInt(timestamp))
|
||||||
|
req.Header.Set("X-HFB-Signature", signExternalUploadForTest(secret, timestamp, body))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler := &Handler{externalUploadSecret: secret}
|
||||||
|
if err := handler.verifyExternalUpload(c, body); err != nil {
|
||||||
|
t.Fatalf("verifyExternalUpload() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyExternalUploadRejectsBadSignature(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
req := httptest.NewRequest("POST", "/api/open/listing-uploads", nil)
|
||||||
|
req.Header.Set("X-HFB-Timestamp", strconvFormatInt(time.Now().Unix()))
|
||||||
|
req.Header.Set("X-HFB-Signature", "bad-signature")
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler := &Handler{externalUploadSecret: "test-upload-secret"}
|
||||||
|
if err := handler.verifyExternalUpload(c, []byte(`{}`)); err != errExternalUploadSignature {
|
||||||
|
t.Fatalf("verifyExternalUpload() error = %v, want errExternalUploadSignature", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyExternalUploadRejectsStaleTimestamp(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
body := []byte(`{}`)
|
||||||
|
secret := "test-upload-secret"
|
||||||
|
timestamp := time.Now().Add(-10 * time.Minute).Unix()
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
req := httptest.NewRequest("POST", "/api/open/listing-uploads", nil)
|
||||||
|
req.Header.Set("X-HFB-Timestamp", strconvFormatInt(timestamp))
|
||||||
|
req.Header.Set("X-HFB-Signature", signExternalUploadForTest(secret, timestamp, body))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler := &Handler{externalUploadSecret: secret}
|
||||||
|
if err := handler.verifyExternalUpload(c, body); err != errExternalUploadTimestamp {
|
||||||
|
t.Fatalf("verifyExternalUpload() error = %v, want errExternalUploadTimestamp", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalUploadIPAllowed(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
allowed []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "empty whitelist", ip: "203.0.113.10", allowed: nil, want: true},
|
||||||
|
{name: "exact match", ip: "203.0.113.10", allowed: []string{"203.0.113.10"}, want: true},
|
||||||
|
{name: "cidr match", ip: "10.1.2.3", allowed: []string{"10.0.0.0/8"}, want: true},
|
||||||
|
{name: "blocked", ip: "203.0.113.10", allowed: []string{"198.51.100.0/24"}, want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := externalUploadIPAllowed(tt.ip, tt.allowed); got != tt.want {
|
||||||
|
t.Fatalf("externalUploadIPAllowed() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signExternalUploadForTest(secret string, timestamp int64, body []byte) string {
|
||||||
|
ts := strconvFormatInt(timestamp)
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(ts))
|
||||||
|
mac.Write([]byte("."))
|
||||||
|
mac.Write(body)
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func strconvFormatInt(value int64) string {
|
||||||
|
return strconv.FormatInt(value, 10)
|
||||||
|
}
|
||||||
@@ -61,8 +61,17 @@ func (h *Handler) Get(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
includeSecret := c.Query("include_secret") == "true"
|
includeSecret := c.Query("include_secret") == "true"
|
||||||
|
var actorID uint64
|
||||||
|
if includeSecret {
|
||||||
|
var ok bool
|
||||||
|
actorID, ok = currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "未授权")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
config, err := h.service.Get(c.Request.Context(), id, includeSecret)
|
config, err := h.service.Get(c.Request.Context(), id, includeSecret, actorID, auditMeta(c))
|
||||||
if err == ErrConfigNotFound {
|
if err == ErrConfigNotFound {
|
||||||
response.NotFound(c, "配置不存在")
|
response.NotFound(c, "配置不存在")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (r *Repository) List(ctx context.Context, query ListQuery) ([]ConfigDTO, in
|
|||||||
// FindByID 根据 ID 查询配置
|
// FindByID 根据 ID 查询配置
|
||||||
|
|
||||||
// FindByID 根据 ID 查询配置
|
// FindByID 根据 ID 查询配置
|
||||||
func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool) (*ConfigDTO, error) {
|
func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool, actorID uint64, meta AuditMeta) (*ConfigDTO, error) {
|
||||||
var item model.PaymentMerchantConfig
|
var item model.PaymentMerchantConfig
|
||||||
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
|
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -77,6 +77,15 @@ func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if includeSecret {
|
||||||
|
if err := appendAuditLog(r.db.WithContext(ctx), actorID, "payment_config.view_secret", item.ID, meta, map[string]any{
|
||||||
|
"name": item.Name,
|
||||||
|
"provider": item.Provider,
|
||||||
|
"merchant_id": item.MerchantID,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ func (s *Service) List(ctx context.Context, query ListQuery) (*ListResponse, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get 获取单个配置
|
// Get 获取单个配置
|
||||||
func (s *Service) Get(ctx context.Context, id uint64, includeSecret bool) (*ConfigDTO, error) {
|
func (s *Service) Get(ctx context.Context, id uint64, includeSecret bool, actorID uint64, meta AuditMeta) (*ConfigDTO, error) {
|
||||||
return s.repo.FindByID(ctx, id, includeSecret)
|
return s.repo.FindByID(ctx, id, includeSecret, actorID, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportBackup 导出支付配置备份。
|
// ExportBackup 导出支付配置备份。
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/config"
|
"hfb_sys/backend/internal/config"
|
||||||
@@ -63,7 +62,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
}
|
}
|
||||||
if cfg.RateLimit.Enabled {
|
if cfg.RateLimit.Enabled {
|
||||||
engine.Use(middleware.RateLimitPerMinute(cfg.RateLimit.RequestsPerMinute))
|
engine.Use(middleware.RateLimitPerMinute(cfg.RateLimit.RequestsPerMinute, deps.Redis))
|
||||||
}
|
}
|
||||||
|
|
||||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
|
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
|
||||||
@@ -166,8 +165,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
var paymentConfigService *paymentconfig.Service
|
var paymentConfigService *paymentconfig.Service
|
||||||
var paymentConfigHandler *paymentconfig.Handler
|
var paymentConfigHandler *paymentconfig.Handler
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
// 从环境变量获取加密密钥,如果没有则使用 MockEncryptor
|
// 生产环境必须配置有效加密密钥;开发/测试缺失时才降级 MockEncryptor。
|
||||||
encryptionKey := os.Getenv("PAYMENT_CONFIG_ENCRYPTION_KEY")
|
encryptionKey := cfg.PaymentConfigEncryptionKey
|
||||||
var encryptor paymentconfig.Encryptor
|
var encryptor paymentconfig.Encryptor
|
||||||
if encryptionKey != "" {
|
if encryptionKey != "" {
|
||||||
if aesEncryptor, err := paymentconfig.NewAESEncryptor(encryptionKey); err == nil {
|
if aesEncryptor, err := paymentconfig.NewAESEncryptor(encryptionKey); err == nil {
|
||||||
@@ -175,6 +174,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if encryptor == nil {
|
if encryptor == nil {
|
||||||
|
if cfg.AppEnv == "production" {
|
||||||
|
logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid")
|
||||||
|
}
|
||||||
encryptor = &paymentconfig.MockEncryptor{}
|
encryptor = &paymentconfig.MockEncryptor{}
|
||||||
logger.Warn("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid, using MockEncryptor")
|
logger.Warn("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid, using MockEncryptor")
|
||||||
}
|
}
|
||||||
@@ -225,7 +227,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
||||||
var adminRoleRepo *adminrole.Repository
|
var adminRoleRepo *adminrole.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
adminRoleRepo = adminrole.NewRepository(deps.DB)
|
adminRoleRepo = adminrole.NewRepository(deps.DB, deps.Redis)
|
||||||
}
|
}
|
||||||
adminRoleService := adminrole.NewService(adminRoleRepo)
|
adminRoleService := adminrole.NewService(adminRoleRepo)
|
||||||
adminRoleHandler := adminrole.NewHandler(adminRoleService)
|
adminRoleHandler := adminrole.NewHandler(adminRoleService)
|
||||||
@@ -246,7 +248,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
fileService := filemodule.NewService(fileStorage)
|
fileService := filemodule.NewService(fileStorage)
|
||||||
fileHandler := filemodule.NewHandler(fileService, fileStorage)
|
fileHandler := filemodule.NewHandler(fileService, fileStorage)
|
||||||
listingService := listing.NewService(listingRepo, systemConfigRepo)
|
listingService := listing.NewService(listingRepo, systemConfigRepo)
|
||||||
listingHandler := listing.NewHandler(listingService, fileStorage)
|
listingHandler := listing.NewHandler(listingService, fileStorage, listing.HandlerOptions{
|
||||||
|
ExternalUploadSecret: cfg.ExternalUploadSecret,
|
||||||
|
ExternalUploadAllowedIPs: cfg.ExternalUploadAllowedIPs,
|
||||||
|
})
|
||||||
var announcementRepo *announcement.Repository
|
var announcementRepo *announcement.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
announcementRepo = announcement.NewRepository(deps.DB)
|
announcementRepo = announcement.NewRepository(deps.DB)
|
||||||
@@ -254,7 +259,20 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
announcementService := announcement.NewService(announcementRepo)
|
announcementService := announcement.NewService(announcementRepo)
|
||||||
announcementHandler := announcement.NewHandler(announcementService)
|
announcementHandler := announcement.NewHandler(announcementService)
|
||||||
requireAuth := middleware.Auth(jwtManager)
|
requireAuth := middleware.Auth(jwtManager)
|
||||||
requireAdmin := middleware.AdminAuth(jwtManager)
|
var validateAdminToken middleware.AdminTokenValidatorFunc
|
||||||
|
if adminAuthRepo != nil {
|
||||||
|
validateAdminToken = func(ctx context.Context, adminID uint64, tokenVersion int64) (middleware.AdminTokenContext, error) {
|
||||||
|
admin, err := adminAuthRepo.FindActiveForToken(ctx, adminID, tokenVersion)
|
||||||
|
if err != nil {
|
||||||
|
return middleware.AdminTokenContext{}, err
|
||||||
|
}
|
||||||
|
return middleware.AdminTokenContext{
|
||||||
|
Username: admin.Username,
|
||||||
|
PasswordMustChange: admin.PasswordMustChange,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requireAdmin := middleware.AdminAuth(jwtManager, validateAdminToken)
|
||||||
requireRealname := middleware.RequireRealname(userRepo)
|
requireRealname := middleware.RequireRealname(userRepo)
|
||||||
requirePerm := func(code string) gin.HandlerFunc {
|
requirePerm := func(code string) gin.HandlerFunc {
|
||||||
return middleware.RequirePermission(code, deps.Redis)
|
return middleware.RequirePermission(code, deps.Redis)
|
||||||
@@ -410,7 +428,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminAuthRoutes.POST("/refresh", adminAuthHandler.Refresh)
|
adminAuthRoutes.POST("/refresh", adminAuthHandler.Refresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
adminRoutes := api.Group("/admin", requireAdmin)
|
adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged())
|
||||||
{
|
{
|
||||||
adminRoutes.GET("/me", adminAuthHandler.Me)
|
adminRoutes.GET("/me", adminAuthHandler.Me)
|
||||||
adminRoutes.PUT("/me/support-status", adminAuthHandler.UpdateSupportStatus)
|
adminRoutes.PUT("/me/support-status", adminAuthHandler.UpdateSupportStatus)
|
||||||
@@ -455,7 +473,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.GET("/payment-configs", requirePerm("payment_config:list"), paymentConfigHandler.List)
|
adminRoutes.GET("/payment-configs", requirePerm("payment_config:list"), paymentConfigHandler.List)
|
||||||
adminRoutes.GET("/payment-configs/export", requirePerm("payment_config:view_secret"), paymentConfigHandler.ExportBackup)
|
adminRoutes.GET("/payment-configs/export", requirePerm("payment_config:view_secret"), paymentConfigHandler.ExportBackup)
|
||||||
adminRoutes.POST("/payment-configs/import", requirePerm("payment_config:view_secret"), paymentConfigHandler.ImportBackup)
|
adminRoutes.POST("/payment-configs/import", requirePerm("payment_config:view_secret"), paymentConfigHandler.ImportBackup)
|
||||||
adminRoutes.GET("/payment-configs/:id", requirePerm("payment_config:list"), paymentConfigHandler.Get)
|
adminRoutes.GET("/payment-configs/:id", requirePerm("payment_config:list"), middleware.RequirePermissionIf("payment_config:view_secret", deps.Redis, func(c *gin.Context) bool {
|
||||||
|
return c.Query("include_secret") == "true"
|
||||||
|
}), paymentConfigHandler.Get)
|
||||||
adminRoutes.POST("/payment-configs", requirePerm("payment_config:create"), paymentConfigHandler.Create)
|
adminRoutes.POST("/payment-configs", requirePerm("payment_config:create"), paymentConfigHandler.Create)
|
||||||
adminRoutes.PUT("/payment-configs/:id", requirePerm("payment_config:update"), paymentConfigHandler.Update)
|
adminRoutes.PUT("/payment-configs/:id", requirePerm("payment_config:update"), paymentConfigHandler.Update)
|
||||||
adminRoutes.DELETE("/payment-configs/:id", requirePerm("payment_config:delete"), paymentConfigHandler.Delete)
|
adminRoutes.DELETE("/payment-configs/:id", requirePerm("payment_config:delete"), paymentConfigHandler.Delete)
|
||||||
@@ -498,7 +518,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Update)
|
adminRoutes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Update)
|
||||||
adminRoutes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Delete)
|
adminRoutes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Delete)
|
||||||
adminRoutes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), adminMgrHandler.AssignRoles)
|
adminRoutes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), adminMgrHandler.AssignRoles)
|
||||||
adminRoutes.PUT("/admin-users/:id/password", adminMgrHandler.ChangePassword)
|
adminRoutes.PUT("/admin-users/me/password", adminMgrHandler.ChangeOwnPassword)
|
||||||
|
adminRoutes.PUT("/admin-users/:id/password", requirePerm("admin_user:manage"), adminMgrHandler.ResetPassword)
|
||||||
|
|
||||||
// 公告管理
|
// 公告管理
|
||||||
adminRoutes.GET("/announcements", requirePerm("announcement:view"), announcementHandler.AdminList)
|
adminRoutes.GET("/announcements", requirePerm("announcement:view"), announcementHandler.AdminList)
|
||||||
|
|||||||
@@ -383,6 +383,8 @@ CREATE TABLE IF NOT EXISTS admin_users (
|
|||||||
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
|
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态: active正常, inactive停用',
|
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态: active正常, inactive停用',
|
||||||
support_status VARCHAR(16) NOT NULL DEFAULT 'offline' COMMENT '客服状态: online在线, offline离线, busy忙碌',
|
support_status VARCHAR(16) NOT NULL DEFAULT 'offline' COMMENT '客服状态: online在线, offline离线, busy忙碌',
|
||||||
|
token_version BIGINT NOT NULL DEFAULT 1 COMMENT '后台凭证版本,递增后旧 token 失效',
|
||||||
|
password_must_change TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否必须先修改密码',
|
||||||
last_login_at DATETIME NULL COMMENT '最后登录时间',
|
last_login_at DATETIME NULL COMMENT '最后登录时间',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
@@ -654,24 +656,8 @@ WHERE r.code = 'finance' AND p.code IN (
|
|||||||
-- 管理员账号初始化
|
-- 管理员账号初始化
|
||||||
-- -------------------------------------------
|
-- -------------------------------------------
|
||||||
|
|
||||||
-- 初始化管理员账号(密码: admin123456)
|
-- 管理员账号不再由迁移创建,首个超级管理员由启动时的
|
||||||
INSERT INTO admin_users (username, password_hash, nickname, status) VALUES
|
-- ADMIN_BOOTSTRAP_USERNAME / ADMIN_BOOTSTRAP_PASSWORD 一次性生成。
|
||||||
('admin', '$2a$10$vvDd.cYJCOBl9kr5jdRYXu9C/7GXcHmXsG17zJPHzPOZ0XGbqcWNS', '超级管理员', 'active'),
|
|
||||||
('kf1', '$2a$10$7cx3B4IWunLZO24o11YVGu4qY1J1naDdsTyvoCpcnOO.GV15qaf3C', '客服1', 'active'),
|
|
||||||
('kf2', '$2a$10$m6kLCXciFygpqIGUZ9LQtuEFAl40hb.S7zJmMiuC83WrU7OQM3TPC', '客服2', 'active')
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
nickname = VALUES(nickname),
|
|
||||||
status = VALUES(status);
|
|
||||||
|
|
||||||
-- admin 关联 super_admin 角色
|
|
||||||
INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id)
|
|
||||||
SELECT au.id, r.id FROM admin_users au, roles r
|
|
||||||
WHERE au.username = 'admin' AND r.code = 'super_admin';
|
|
||||||
|
|
||||||
-- 客服关联 cs 角色
|
|
||||||
INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id)
|
|
||||||
SELECT au.id, r.id FROM admin_users au, roles r
|
|
||||||
WHERE au.username IN ('kf1', 'kf2') AND r.code = 'cs';
|
|
||||||
|
|
||||||
-- -------------------------------------------
|
-- -------------------------------------------
|
||||||
-- 系统配置初始化
|
-- 系统配置初始化
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN IF NOT EXISTS token_version BIGINT NOT NULL DEFAULT 1 COMMENT '后台凭证版本,递增后旧 token 失效'
|
||||||
|
AFTER support_status;
|
||||||
|
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN IF NOT EXISTS password_must_change TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否必须先修改密码'
|
||||||
|
AFTER token_version;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
DROP COLUMN IF EXISTS password_must_change;
|
||||||
|
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
DROP COLUMN IF EXISTS token_version;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
X-Frame-Options "DENY"
|
X-Frame-Options "DENY"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 聊天 SSE 长连接:关闭缓冲,逐条推送。
|
# 聊天 SSE 长连接:关闭缓冲,逐条推送。
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import axios from 'axios'
|
|||||||
|
|
||||||
import { apiClient } from '@/shared/api/client'
|
import { apiClient } from '@/shared/api/client'
|
||||||
import type { ApiResponse } from '@/shared/types/types'
|
import type { ApiResponse } from '@/shared/types/types'
|
||||||
import { getRefreshToken, setAuthTokens } from '@/shared/utils/authStorage'
|
|
||||||
import type { UserStatus } from '@/shared/types/status'
|
import type { UserStatus } from '@/shared/types/status'
|
||||||
|
|
||||||
export interface AdminRole {
|
export interface AdminRole {
|
||||||
@@ -17,21 +16,19 @@ export interface AdminUser {
|
|||||||
nickname: string
|
nickname: string
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
support_status: 'online' | 'offline' | 'busy'
|
support_status: 'online' | 'offline' | 'busy'
|
||||||
|
password_must_change: boolean
|
||||||
roles: AdminRole[]
|
roles: AdminRole[]
|
||||||
permissions: string[]
|
permissions: string[]
|
||||||
last_login_at?: string
|
last_login_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminTokenPair {
|
export interface AdminRefreshData {
|
||||||
access_token: string
|
refreshed: boolean
|
||||||
refresh_token: string
|
|
||||||
token_type: string
|
|
||||||
expires_in: number
|
expires_in: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminLoginData {
|
export interface AdminLoginData {
|
||||||
admin: AdminUser
|
admin: AdminUser
|
||||||
tokens: AdminTokenPair
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminCaptcha {
|
export interface AdminCaptcha {
|
||||||
@@ -82,13 +79,10 @@ export async function updateSupportStatus(status: 'online' | 'offline' | 'busy')
|
|||||||
|
|
||||||
/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
|
/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
|
||||||
export async function refreshAdminSession() {
|
export async function refreshAdminSession() {
|
||||||
const refreshToken = getRefreshToken('admin')
|
const { data } = await axios.post<ApiResponse<AdminRefreshData>>(
|
||||||
if (!refreshToken) throw new Error('no refresh token')
|
|
||||||
const { data } = await axios.post<ApiResponse<AdminTokenPair>>(
|
|
||||||
'/api/admin/auth/refresh',
|
'/api/admin/auth/refresh',
|
||||||
{ refresh_token: refreshToken },
|
{},
|
||||||
{ timeout: 10000 }
|
{ timeout: 10000, withCredentials: true }
|
||||||
)
|
)
|
||||||
setAuthTokens('admin', data.data)
|
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export interface ChangePasswordRequest {
|
|||||||
new_password: string
|
new_password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordRequest {
|
||||||
|
new_password: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAdminMgrUsers(page = 1, pageSize = 20) {
|
export async function fetchAdminMgrUsers(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>(
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>(
|
||||||
'/admin/admin-users',
|
'/admin/admin-users',
|
||||||
@@ -78,7 +82,15 @@ export async function assignAdminRoles(id: number, roleIds: number[]) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changeAdminPassword(id: number, req: ChangePasswordRequest) {
|
export async function changeAdminPassword(req: ChangePasswordRequest) {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
|
||||||
|
'/admin/admin-users/me/password',
|
||||||
|
req
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAdminPassword(id: number, req: ResetPasswordRequest) {
|
||||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
|
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
|
||||||
`/admin/admin-users/${id}/password`,
|
`/admin/admin-users/${id}/password`,
|
||||||
req
|
req
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export {
|
|||||||
fetchAdminMe,
|
fetchAdminMe,
|
||||||
updateSupportStatus,
|
updateSupportStatus,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
type AdminTokenPair,
|
type AdminRefreshData,
|
||||||
type AdminLoginData,
|
type AdminLoginData,
|
||||||
type AdminCaptcha,
|
type AdminCaptcha,
|
||||||
} from './api/adminAuth'
|
} from './api/adminAuth'
|
||||||
|
|||||||
@@ -3,19 +3,20 @@ import { readError } from '@/shared/utils/error'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Lock, User } from '@element-plus/icons-vue'
|
import { Lock, User } from '@element-plus/icons-vue'
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { fetchAdminCaptcha, type AdminCaptcha } from '@/features/admin'
|
import { fetchAdminCaptcha, type AdminCaptcha } from '@/features/admin'
|
||||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const adminSession = useAdminSessionStore()
|
const adminSession = useAdminSessionStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const captchaLoading = ref(false)
|
const captchaLoading = ref(false)
|
||||||
const captcha = ref<AdminCaptcha | null>(null)
|
const captcha = ref<AdminCaptcha | null>(null)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: 'admin',
|
username: '',
|
||||||
password: 'admin123456',
|
password: '',
|
||||||
captchaCode: '',
|
captchaCode: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -43,7 +44,8 @@ async function handleLogin() {
|
|||||||
form.captchaCode
|
form.captchaCode
|
||||||
)
|
)
|
||||||
ElMessage.success('后台登录成功')
|
ElMessage.success('后台登录成功')
|
||||||
await router.push('/admin/dashboard')
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
|
||||||
|
await router.push(redirect.startsWith('/admin') ? redirect : '/admin/dashboard')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(readError(error, '后台登录失败'))
|
ElMessage.error(readError(error, '后台登录失败'))
|
||||||
await loadCaptcha()
|
await loadCaptcha()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ref } from 'vue'
|
|||||||
import {
|
import {
|
||||||
fetchAdminMgrUsers,
|
fetchAdminMgrUsers,
|
||||||
deleteAdminMgrUser,
|
deleteAdminMgrUser,
|
||||||
changeAdminPassword,
|
resetAdminPassword,
|
||||||
type AdminMgrUser,
|
type AdminMgrUser,
|
||||||
} from '@/features/admin/api/adminMgr'
|
} from '@/features/admin/api/adminMgr'
|
||||||
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
|
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
|
||||||
@@ -22,7 +22,7 @@ const showRolesDialog = ref(false)
|
|||||||
const rolesAdmin = ref<AdminMgrUser | null>(null)
|
const rolesAdmin = ref<AdminMgrUser | null>(null)
|
||||||
const showPasswordDialog = ref(false)
|
const showPasswordDialog = ref(false)
|
||||||
const passwordAdmin = ref<AdminMgrUser | null>(null)
|
const passwordAdmin = ref<AdminMgrUser | null>(null)
|
||||||
const passwordForm = ref({ old_password: '', new_password: '' })
|
const passwordForm = ref({ new_password: '' })
|
||||||
const passwordSubmitting = ref(false)
|
const passwordSubmitting = ref(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -53,7 +53,7 @@ function openRoles(row: AdminMgrUser) {
|
|||||||
|
|
||||||
function openPassword(row: AdminMgrUser) {
|
function openPassword(row: AdminMgrUser) {
|
||||||
passwordAdmin.value = row
|
passwordAdmin.value = row
|
||||||
passwordForm.value = { old_password: '', new_password: '' }
|
passwordForm.value = { new_password: '' }
|
||||||
showPasswordDialog.value = true
|
showPasswordDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,17 +78,17 @@ async function handleDelete(row: AdminMgrUser) {
|
|||||||
|
|
||||||
async function handleChangePassword() {
|
async function handleChangePassword() {
|
||||||
if (!passwordAdmin.value) return
|
if (!passwordAdmin.value) return
|
||||||
if (!passwordForm.value.old_password || !passwordForm.value.new_password) {
|
if (!passwordForm.value.new_password) {
|
||||||
ElMessage.warning('请填写完整')
|
ElMessage.warning('请填写新密码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
passwordSubmitting.value = true
|
passwordSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await changeAdminPassword(passwordAdmin.value.id, passwordForm.value)
|
await resetAdminPassword(passwordAdmin.value.id, passwordForm.value)
|
||||||
ElMessage.success('密码已修改')
|
ElMessage.success('密码已重置,目标账号需要重新登录')
|
||||||
showPasswordDialog.value = false
|
showPasswordDialog.value = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(readError(error, '修改失败'))
|
ElMessage.error(readError(error, '重置失败'))
|
||||||
} finally {
|
} finally {
|
||||||
passwordSubmitting.value = false
|
passwordSubmitting.value = false
|
||||||
}
|
}
|
||||||
@@ -169,35 +169,27 @@ const statusLabel: Record<string, string> = {
|
|||||||
<!-- 角色分配对话框 -->
|
<!-- 角色分配对话框 -->
|
||||||
<AssignRolesDialog v-model="showRolesDialog" :admin="rolesAdmin" @saved="loadAdmins" />
|
<AssignRolesDialog v-model="showRolesDialog" :admin="rolesAdmin" @saved="loadAdmins" />
|
||||||
|
|
||||||
<!-- 修改密码对话框 -->
|
<!-- 重置密码对话框 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="showPasswordDialog"
|
:model-value="showPasswordDialog"
|
||||||
:title="`修改密码 - ${passwordAdmin?.username || ''}`"
|
:title="`重置密码 - ${passwordAdmin?.username || ''}`"
|
||||||
width="460px"
|
width="460px"
|
||||||
@update:model-value="showPasswordDialog = $event"
|
@update:model-value="showPasswordDialog = $event"
|
||||||
>
|
>
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<el-form-item label="原密码" class="full-control">
|
|
||||||
<el-input
|
|
||||||
v-model="passwordForm.old_password"
|
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
placeholder="请输入原密码"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="新密码" class="full-control">
|
<el-form-item label="新密码" class="full-control">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="passwordForm.new_password"
|
v-model="passwordForm.new_password"
|
||||||
type="password"
|
type="password"
|
||||||
show-password
|
show-password
|
||||||
placeholder="请输入新密码(至少6位)"
|
placeholder="至少 8 位,包含字母和数字"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="showPasswordDialog = false">取消</el-button>
|
<el-button @click="showPasswordDialog = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword"
|
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword"
|
||||||
>确认修改</el-button
|
>确认重置</el-button
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
@@ -27,17 +27,24 @@ import {
|
|||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElSubMenu } from 'element-plus'
|
import { ElMessage, ElSubMenu } from 'element-plus'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { logoutAdmin, updateSupportStatus } from '@/features/admin'
|
import { changeAdminPassword, logoutAdmin, updateSupportStatus } from '@/features/admin'
|
||||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const adminSession = useAdminSessionStore()
|
const adminSession = useAdminSessionStore()
|
||||||
const isCollapsed = ref(false)
|
const isCollapsed = ref(false)
|
||||||
const updatingStatus = ref(false)
|
const updatingStatus = ref(false)
|
||||||
|
const passwordSubmitting = ref(false)
|
||||||
|
const passwordForm = reactive({
|
||||||
|
old_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
})
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -231,6 +238,39 @@ async function handleLogout() {
|
|||||||
ElMessage.success('已退出后台')
|
ElMessage.success('已退出后台')
|
||||||
await router.push('/admin/login')
|
await router.push('/admin/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function passwordStrongEnough(value: string) {
|
||||||
|
return value.length >= 8 && /[A-Za-z]/.test(value) && /\d/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForcedPasswordChange() {
|
||||||
|
if (!passwordForm.old_password || !passwordForm.new_password) {
|
||||||
|
ElMessage.error('请输入原密码和新密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!passwordStrongEnough(passwordForm.new_password)) {
|
||||||
|
ElMessage.error('新密码至少 8 位且需包含字母和数字')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (passwordForm.new_password !== passwordForm.confirm_password) {
|
||||||
|
ElMessage.error('两次输入的新密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passwordSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await changeAdminPassword({
|
||||||
|
old_password: passwordForm.old_password,
|
||||||
|
new_password: passwordForm.new_password,
|
||||||
|
})
|
||||||
|
ElMessage.success('密码已修改,请重新登录')
|
||||||
|
adminSession.logout()
|
||||||
|
await router.replace('/admin/login')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '密码修改失败'))
|
||||||
|
} finally {
|
||||||
|
passwordSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -332,6 +372,51 @@ async function handleLogout() {
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
:model-value="adminSession.passwordMustChange"
|
||||||
|
title="修改初始密码"
|
||||||
|
width="420px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
:show-close="false"
|
||||||
|
append-to-body
|
||||||
|
class="force-password-dialog"
|
||||||
|
>
|
||||||
|
<el-form class="force-password-form" label-position="top" @submit.prevent>
|
||||||
|
<el-form-item label="原密码">
|
||||||
|
<el-input
|
||||||
|
v-model="passwordForm.old_password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码">
|
||||||
|
<el-input
|
||||||
|
v-model="passwordForm.new_password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认新密码">
|
||||||
|
<el-input
|
||||||
|
v-model="passwordForm.confirm_password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
@keyup.enter="handleForcedPasswordChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="passwordSubmitting" @click="handleLogout">退出登录</el-button>
|
||||||
|
<el-button type="primary" :loading="passwordSubmitting" @click="handleForcedPasswordChange">
|
||||||
|
确认修改
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -572,4 +657,13 @@ async function handleLogout() {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.force-password-dialog) {
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-password-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -77,20 +77,29 @@ router.beforeEach(async to => {
|
|||||||
|
|
||||||
const adminSession = useAdminSessionStore()
|
const adminSession = useAdminSessionStore()
|
||||||
adminSession.syncFromStorage()
|
adminSession.syncFromStorage()
|
||||||
if (to.path === '/admin/login' && adminSession.token) {
|
if (to.path === '/admin/login') {
|
||||||
return '/admin/dashboard'
|
if (!adminSession.hasSessionHint) return true
|
||||||
}
|
|
||||||
if (to.meta.requiresAdmin) {
|
|
||||||
if (!adminSession.token) {
|
|
||||||
return '/admin/login'
|
|
||||||
}
|
|
||||||
if (adminSession.permissions.length === 0) {
|
|
||||||
try {
|
try {
|
||||||
await adminSession.loadMe()
|
await adminSession.loadMe()
|
||||||
|
return '/admin/dashboard'
|
||||||
} catch {
|
} catch {
|
||||||
// 权限加载失败,仍然允许访问(降级为无权限状态)
|
adminSession.logout()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (to.meta.requiresAdmin) {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!adminSession.hasSessionHint ||
|
||||||
|
adminSession.permissions.length === 0 ||
|
||||||
|
adminSession.passwordMustChange
|
||||||
|
) {
|
||||||
|
await adminSession.loadMe()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
adminSession.logout()
|
||||||
|
return { path: '/admin/login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import type { ApiResponse } from '@/shared/types/types'
|
|||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 30000, // 增加到 30 秒,避免大文件上传超时
|
timeout: 30000, // 增加到 30 秒,避免大文件上传超时
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -149,11 +150,18 @@ function rejectPendingRequests(scope: AuthScope, error: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(scope: AuthScope): Promise<string> {
|
export async function refreshAccessToken(scope: AuthScope): Promise<string> {
|
||||||
|
if (scope === 'admin') {
|
||||||
|
await axios.post('/api/admin/auth/refresh', {}, { timeout: 10000, withCredentials: true })
|
||||||
|
return ''
|
||||||
|
}
|
||||||
const refreshToken = getRefreshToken(scope)
|
const refreshToken = getRefreshToken(scope)
|
||||||
if (!refreshToken) throw new Error('no refresh token')
|
if (!refreshToken) throw new Error('no refresh token')
|
||||||
|
|
||||||
const endpoint = scope === 'admin' ? '/api/admin/auth/refresh' : '/api/auth/refresh'
|
const { data } = await axios.post(
|
||||||
const { data } = await axios.post(endpoint, { refresh_token: refreshToken }, { timeout: 10000 })
|
'/api/auth/refresh',
|
||||||
|
{ refresh_token: refreshToken },
|
||||||
|
{ timeout: 10000 }
|
||||||
|
)
|
||||||
const tokens = {
|
const tokens = {
|
||||||
access_token: data.data.access_token,
|
access_token: data.data.access_token,
|
||||||
refresh_token: data.data.refresh_token,
|
refresh_token: data.data.refresh_token,
|
||||||
@@ -239,7 +247,9 @@ apiClient.interceptors.response.use(
|
|||||||
state.pendingRequests.push({ resolve, reject })
|
state.pendingRequests.push({ resolve, reject })
|
||||||
}).then(newToken => {
|
}).then(newToken => {
|
||||||
originalRequest._retry = true
|
originalRequest._retry = true
|
||||||
|
if (newToken) {
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
}
|
||||||
return apiClient(originalRequest)
|
return apiClient(originalRequest)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -249,7 +259,9 @@ apiClient.interceptors.response.use(
|
|||||||
const newToken = await refreshAccessToken(scope)
|
const newToken = await refreshAccessToken(scope)
|
||||||
resolvePendingRequests(scope, newToken)
|
resolvePendingRequests(scope, newToken)
|
||||||
originalRequest._retry = true
|
originalRequest._retry = true
|
||||||
|
if (newToken) {
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
}
|
||||||
return apiClient(originalRequest)
|
return apiClient(originalRequest)
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
rejectPendingRequests(scope, refreshError)
|
rejectPendingRequests(scope, refreshError)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const userKeys = {
|
|||||||
const adminKeys = {
|
const adminKeys = {
|
||||||
accessToken: 'admin_access_token',
|
accessToken: 'admin_access_token',
|
||||||
refreshToken: 'admin_refresh_token',
|
refreshToken: 'admin_refresh_token',
|
||||||
profile: ['admin_id', 'admin_username'],
|
profile: ['admin_id', 'admin_username', 'admin_support_status', 'admin_password_must_change'],
|
||||||
}
|
}
|
||||||
|
|
||||||
function keysFor(scope: AuthScope) {
|
function keysFor(scope: AuthScope) {
|
||||||
@@ -22,15 +22,23 @@ function keysFor(scope: AuthScope) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAccessToken(scope: AuthScope) {
|
export function getAccessToken(scope: AuthScope) {
|
||||||
|
if (scope === 'admin') return ''
|
||||||
return localStorage.getItem(keysFor(scope).accessToken) || ''
|
return localStorage.getItem(keysFor(scope).accessToken) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRefreshToken(scope: AuthScope) {
|
export function getRefreshToken(scope: AuthScope) {
|
||||||
|
if (scope === 'admin') return ''
|
||||||
return localStorage.getItem(keysFor(scope).refreshToken) || ''
|
return localStorage.getItem(keysFor(scope).refreshToken) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAuthTokens(scope: AuthScope, tokens: AuthTokenPair) {
|
export function setAuthTokens(scope: AuthScope, tokens: AuthTokenPair) {
|
||||||
const keys = keysFor(scope)
|
const keys = keysFor(scope)
|
||||||
|
if (scope === 'admin') {
|
||||||
|
localStorage.removeItem(keys.accessToken)
|
||||||
|
localStorage.removeItem(keys.refreshToken)
|
||||||
|
notifyAuthStorageChanged(scope)
|
||||||
|
return
|
||||||
|
}
|
||||||
localStorage.setItem(keys.accessToken, tokens.access_token)
|
localStorage.setItem(keys.accessToken, tokens.access_token)
|
||||||
localStorage.setItem(keys.refreshToken, tokens.refresh_token)
|
localStorage.setItem(keys.refreshToken, tokens.refresh_token)
|
||||||
notifyAuthStorageChanged(scope)
|
notifyAuthStorageChanged(scope)
|
||||||
|
|||||||
@@ -6,17 +6,12 @@ import {
|
|||||||
type AdminRole,
|
type AdminRole,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
} from '@/features/admin/api/adminAuth'
|
} from '@/features/admin/api/adminAuth'
|
||||||
import {
|
import { clearAuthStorage } from '@/shared/utils/authStorage'
|
||||||
clearAuthStorage,
|
|
||||||
getAccessToken,
|
|
||||||
getRefreshToken,
|
|
||||||
setAuthTokens,
|
|
||||||
} from '@/shared/utils/authStorage'
|
|
||||||
|
|
||||||
export const useAdminSessionStore = defineStore('adminSession', {
|
export const useAdminSessionStore = defineStore('adminSession', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
token: getAccessToken('admin'),
|
token: '',
|
||||||
refreshToken: getRefreshToken('admin'),
|
refreshToken: '',
|
||||||
adminId: Number(localStorage.getItem('admin_id') || 0),
|
adminId: Number(localStorage.getItem('admin_id') || 0),
|
||||||
username: localStorage.getItem('admin_username') || '',
|
username: localStorage.getItem('admin_username') || '',
|
||||||
nickname: '',
|
nickname: '',
|
||||||
@@ -24,10 +19,12 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
|||||||
| 'online'
|
| 'online'
|
||||||
| 'offline'
|
| 'offline'
|
||||||
| 'busy',
|
| 'busy',
|
||||||
|
passwordMustChange: localStorage.getItem('admin_password_must_change') === 'true',
|
||||||
roles: [] as AdminRole[],
|
roles: [] as AdminRole[],
|
||||||
permissions: [] as string[],
|
permissions: [] as string[],
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
|
hasSessionHint: state => state.adminId > 0 || state.username !== '',
|
||||||
hasPermission: state => {
|
hasPermission: state => {
|
||||||
return (code: string) => state.permissions.includes(code) || state.permissions.includes('*')
|
return (code: string) => state.permissions.includes(code) || state.permissions.includes('*')
|
||||||
},
|
},
|
||||||
@@ -41,7 +38,7 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
|||||||
actions: {
|
actions: {
|
||||||
async login(username: string, password: string, captchaId: string, captchaCode: string) {
|
async login(username: string, password: string, captchaId: string, captchaCode: string) {
|
||||||
const result = await loginAdmin(username, password, captchaId, captchaCode)
|
const result = await loginAdmin(username, password, captchaId, captchaCode)
|
||||||
this.applySession(result.admin, result.tokens.access_token, result.tokens.refresh_token)
|
this.applySession(result.admin)
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
async loadMe() {
|
async loadMe() {
|
||||||
@@ -56,28 +53,27 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
|||||||
this.username = ''
|
this.username = ''
|
||||||
this.nickname = ''
|
this.nickname = ''
|
||||||
this.supportStatus = 'offline'
|
this.supportStatus = 'offline'
|
||||||
|
this.passwordMustChange = false
|
||||||
this.roles = []
|
this.roles = []
|
||||||
this.permissions = []
|
this.permissions = []
|
||||||
clearAuthStorage('admin')
|
clearAuthStorage('admin')
|
||||||
localStorage.removeItem('admin_support_status')
|
localStorage.removeItem('admin_support_status')
|
||||||
|
localStorage.removeItem('admin_password_must_change')
|
||||||
},
|
},
|
||||||
syncFromStorage() {
|
syncFromStorage() {
|
||||||
this.token = getAccessToken('admin')
|
this.token = ''
|
||||||
this.refreshToken = getRefreshToken('admin')
|
this.refreshToken = ''
|
||||||
this.adminId = Number(localStorage.getItem('admin_id') || 0)
|
this.adminId = Number(localStorage.getItem('admin_id') || 0)
|
||||||
this.username = localStorage.getItem('admin_username') || ''
|
this.username = localStorage.getItem('admin_username') || ''
|
||||||
this.supportStatus = (localStorage.getItem('admin_support_status') || 'offline') as
|
this.supportStatus = (localStorage.getItem('admin_support_status') || 'offline') as
|
||||||
| 'online'
|
| 'online'
|
||||||
| 'offline'
|
| 'offline'
|
||||||
| 'busy'
|
| 'busy'
|
||||||
|
this.passwordMustChange = localStorage.getItem('admin_password_must_change') === 'true'
|
||||||
},
|
},
|
||||||
applySession(admin: AdminUser, accessToken: string, refreshToken: string) {
|
applySession(admin: AdminUser) {
|
||||||
this.token = accessToken
|
this.token = ''
|
||||||
this.refreshToken = refreshToken
|
this.refreshToken = ''
|
||||||
setAuthTokens('admin', {
|
|
||||||
access_token: accessToken,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
})
|
|
||||||
this.applyAdmin(admin)
|
this.applyAdmin(admin)
|
||||||
},
|
},
|
||||||
applyAdmin(admin: AdminUser) {
|
applyAdmin(admin: AdminUser) {
|
||||||
@@ -85,11 +81,13 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
|||||||
this.username = admin.username
|
this.username = admin.username
|
||||||
this.nickname = admin.nickname
|
this.nickname = admin.nickname
|
||||||
this.supportStatus = admin.support_status || 'offline'
|
this.supportStatus = admin.support_status || 'offline'
|
||||||
|
this.passwordMustChange = admin.password_must_change
|
||||||
this.roles = admin.roles || []
|
this.roles = admin.roles || []
|
||||||
this.permissions = admin.permissions || []
|
this.permissions = admin.permissions || []
|
||||||
localStorage.setItem('admin_id', String(admin.id))
|
localStorage.setItem('admin_id', String(admin.id))
|
||||||
localStorage.setItem('admin_username', admin.username)
|
localStorage.setItem('admin_username', admin.username)
|
||||||
localStorage.setItem('admin_support_status', admin.support_status || 'offline')
|
localStorage.setItem('admin_support_status', admin.support_status || 'offline')
|
||||||
|
localStorage.setItem('admin_password_must_change', String(admin.password_must_change))
|
||||||
},
|
},
|
||||||
setSupportStatus(status: 'online' | 'offline' | 'busy') {
|
setSupportStatus(status: 'online' | 'offline' | 'busy') {
|
||||||
this.supportStatus = status
|
this.supportStatus = status
|
||||||
|
|||||||
+1
-1
@@ -588,7 +588,7 @@ main() {
|
|||||||
log_success "开发环境已启动"
|
log_success "开发环境已启动"
|
||||||
if [[ "${NO_FRONTEND}" == "0" ]]; then
|
if [[ "${NO_FRONTEND}" == "0" ]]; then
|
||||||
log "前台:http://localhost:5173"
|
log "前台:http://localhost:5173"
|
||||||
log "后台:http://localhost:5173/admin/login(admin / admin123456)"
|
log "后台:http://localhost:5173/admin/login"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行"
|
log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行"
|
||||||
|
|||||||
+12
-4
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -266,12 +267,19 @@ func initAdminToken(baseURL string) *AdminToken {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录(使用默认管理员账号,验证码留空mock会通过)
|
adminUsername := os.Getenv("LOAD_STRESS_ADMIN_USERNAME")
|
||||||
|
adminPassword := os.Getenv("LOAD_STRESS_ADMIN_PASSWORD")
|
||||||
|
if adminUsername == "" || adminPassword == "" {
|
||||||
|
fmt.Println(" 跳过后台登录:请设置 LOAD_STRESS_ADMIN_USERNAME 和 LOAD_STRESS_ADMIN_PASSWORD")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录后台账号
|
||||||
loginPayload := map[string]string{
|
loginPayload := map[string]string{
|
||||||
"username": "admin",
|
"username": adminUsername,
|
||||||
"password": "admin123456",
|
"password": adminPassword,
|
||||||
"captcha_id": captchaResp.Data.CaptchaID,
|
"captcha_id": captchaResp.Data.CaptchaID,
|
||||||
"captcha": "1234", // mock模式会自动通过
|
"captcha_code": "1234",
|
||||||
}
|
}
|
||||||
loginBody, _ := json.Marshal(loginPayload)
|
loginBody, _ := json.Marshal(loginPayload)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user