From 77fa0cadd2c59d7e0cf8f3b575c8880b386d0ece Mon Sep 17 00:00:00 2001 From: shanshanzhong147 Date: Tue, 16 Jun 2026 06:28:47 -0700 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD(#48):=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=B0=81=E7=A6=81=E9=93=BE=E8=B7=AF=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- go.mod | 2 + go.sum | 4 + internal/config/cacheKey.go | 3 + .../user/kickOfflineByUserDeviceLogic.go | 81 +----- internal/logic/admin/user/session.go | 81 ++++++ internal/logic/admin/user/session_test.go | 74 +++++ .../admin/user/updateUserBasicInfoLogic.go | 21 ++ internal/logic/auth/admin/adminLoginLogic.go | 6 + internal/logic/auth/emailLoginLogic.go | 6 + .../auth/oauth/oAuthLoginGetTokenLogic.go | 6 + internal/logic/auth/telephoneLoginLogic.go | 5 + internal/logic/auth/userLoginLogic.go | 6 + internal/logic/auth/userRegisterLogic.go | 2 +- internal/logic/common/user_enable.go | 89 ++++++ internal/logic/common/user_enable_test.go | 266 ++++++++++++++++++ internal/logic/subscribe/subscribeLogic.go | 5 + internal/middleware/authMiddleware.go | 7 +- 17 files changed, 581 insertions(+), 83 deletions(-) create mode 100644 internal/logic/admin/user/session.go create mode 100644 internal/logic/admin/user/session_test.go create mode 100644 internal/logic/common/user_enable.go create mode 100644 internal/logic/common/user_enable_test.go diff --git a/go.mod b/go.mod index 3dda44b..8870abd 100644 --- a/go.mod +++ b/go.mod @@ -82,6 +82,7 @@ require ( github.com/alibabacloud-go/tea-utils v1.4.5 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect github.com/alibabacloud-go/tea-xml v1.1.3 // indirect + github.com/alicebob/miniredis/v2 v2.35.0 // indirect github.com/aliyun/credentials-go v1.3.10 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect @@ -145,6 +146,7 @@ require ( github.com/tjfoc/gmsm v1.4.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect diff --git a/go.sum b/go.sum index 19ed347..f79ecc4 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/ github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0= github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA= @@ -395,6 +397,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= diff --git a/internal/config/cacheKey.go b/internal/config/cacheKey.go index ea53d5c..a3d65cb 100644 --- a/internal/config/cacheKey.go +++ b/internal/config/cacheKey.go @@ -70,3 +70,6 @@ const RegisterIpKeyPrefix = "register:ip:" // UserSessionsKeyPrefix per-user sessions zset key prefix const UserSessionsKeyPrefix = "auth:user_sessions:" + +// UserEnableKeyPrefix user enable state cache key prefix +const UserEnableKeyPrefix = "user:enable:" diff --git a/internal/logic/admin/user/kickOfflineByUserDeviceLogic.go b/internal/logic/admin/user/kickOfflineByUserDeviceLogic.go index fe7472d..23395db 100644 --- a/internal/logic/admin/user/kickOfflineByUserDeviceLogic.go +++ b/internal/logic/admin/user/kickOfflineByUserDeviceLogic.go @@ -2,11 +2,7 @@ package user import ( "context" - "fmt" - "strconv" - "strings" - "github.com/perfect-panel/server/internal/config" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" @@ -50,84 +46,11 @@ func (l *KickOfflineByUserDeviceLogic) KickOfflineByUserDevice(req *types.KickOf // clearAllSessions 清除指定用户的所有会话(通过 SCAN 查找,不依赖 sorted set) func (l *KickOfflineByUserDeviceLogic) clearAllSessions(userId int64) { - sessionSet := make(map[string]struct{}) - - userIDText := strconv.FormatInt(userId, 10) - pattern := fmt.Sprintf("%s:*", config.SessionIdKey) - var cursor uint64 - for { - keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, pattern, 200).Result() - if scanErr != nil { - l.Errorw("扫描会话键失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error())) - break - } - for _, sessionKey := range keys { - value, getErr := l.svcCtx.Redis.Get(l.ctx, sessionKey).Result() - if getErr != nil || value != userIDText { - continue - } - sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":") - if sessionID == "" || strings.HasPrefix(sessionID, "detail:") { - continue - } - sessionSet[sessionID] = struct{}{} - } - cursor = nextCursor - if cursor == 0 { - break - } - } - - deviceKeySet := make(map[string]struct{}) - devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey) - cursor = 0 - for { - keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, devicePattern, 200).Result() - if scanErr != nil { - l.Errorw("扫描设备会话映射失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error())) - break - } - for _, deviceKey := range keys { - sessionID, getErr := l.svcCtx.Redis.Get(l.ctx, deviceKey).Result() - if getErr != nil { - continue - } - if _, exists := sessionSet[sessionID]; exists { - deviceKeySet[deviceKey] = struct{}{} - } - } - cursor = nextCursor - if cursor == 0 { - break - } - } - - if len(sessionSet) == 0 { - return - } - - sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId) - pipe := l.svcCtx.Redis.TxPipeline() - for sessionID := range sessionSet { - pipe.Del(l.ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID)) - pipe.Del(l.ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID)) - pipe.ZRem(l.ctx, sessionsKey, sessionID) - } - pipe.Del(l.ctx, sessionsKey) - - for deviceKey := range deviceKeySet { - pipe.Del(l.ctx, deviceKey) - } - - if _, err := pipe.Exec(l.ctx); err != nil { + if err := clearAllSessions(l.ctx, l.svcCtx, userId); err != nil { l.Errorw("清理会话缓存失败", logger.Field("user_id", userId), logger.Field("error", err.Error()), ) + return } - - l.Infow("[KickOffline] 管理员踢设备-清除所有Session", - logger.Field("user_id", userId), - logger.Field("count", len(sessionSet)), - ) } diff --git a/internal/logic/admin/user/session.go b/internal/logic/admin/user/session.go new file mode 100644 index 0000000..dc1e450 --- /dev/null +++ b/internal/logic/admin/user/session.go @@ -0,0 +1,81 @@ +package user + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" +) + +func clearAllSessions(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) error { + userIDText := strconv.FormatInt(userID, 10) + sessionSet := make(map[string]struct{}) + + pattern := fmt.Sprintf("%s:*", config.SessionIdKey) + var cursor uint64 + for { + keys, nextCursor, err := svcCtx.Redis.Scan(ctx, cursor, pattern, 200).Result() + if err != nil { + return err + } + for _, sessionKey := range keys { + value, err := svcCtx.Redis.Get(ctx, sessionKey).Result() + if err != nil || value != userIDText { + continue + } + sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":") + if sessionID == "" || strings.HasPrefix(sessionID, "detail:") { + continue + } + sessionSet[sessionID] = struct{}{} + } + cursor = nextCursor + if cursor == 0 { + break + } + } + + if len(sessionSet) == 0 { + return nil + } + + deviceKeySet := make(map[string]struct{}) + devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey) + cursor = 0 + for { + keys, nextCursor, err := svcCtx.Redis.Scan(ctx, cursor, devicePattern, 200).Result() + if err != nil { + return err + } + for _, deviceKey := range keys { + sessionID, err := svcCtx.Redis.Get(ctx, deviceKey).Result() + if err != nil { + continue + } + if _, exists := sessionSet[sessionID]; exists { + deviceKeySet[deviceKey] = struct{}{} + } + } + cursor = nextCursor + if cursor == 0 { + break + } + } + + sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userID) + pipe := svcCtx.Redis.TxPipeline() + for sessionID := range sessionSet { + pipe.Del(ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID)) + pipe.Del(ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID)) + pipe.ZRem(ctx, sessionsKey, sessionID) + } + pipe.Del(ctx, sessionsKey) + for deviceKey := range deviceKeySet { + pipe.Del(ctx, deviceKey) + } + _, err := pipe.Exec(ctx) + return err +} diff --git a/internal/logic/admin/user/session_test.go b/internal/logic/admin/user/session_test.go new file mode 100644 index 0000000..9a106d9 --- /dev/null +++ b/internal/logic/admin/user/session_test.go @@ -0,0 +1,74 @@ +package user + +import ( + "context" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" + "github.com/redis/go-redis/v9" +) + +func TestClearAllSessions(t *testing.T) { + redisServer, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis.Run() error = %v", err) + } + defer redisServer.Close() + + rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + defer rdb.Close() + + svcCtx := &svc.ServiceContext{Redis: rdb} + ctx := context.Background() + + userID := int64(42) + sessionID := "session-a" + otherSessionID := "session-b" + userSessionKey := config.SessionIdKey + ":" + sessionID + userDetailKey := config.SessionIdKey + ":detail:" + sessionID + otherSessionKey := config.SessionIdKey + ":" + otherSessionID + userSessionsZSet := config.UserSessionsKeyPrefix + "42" + deviceKey := config.DeviceCacheKeyKey + ":device-1" + unrelatedDeviceKey := config.DeviceCacheKeyKey + ":device-2" + + setString(t, redisServer, userSessionKey, "42") + setString(t, redisServer, userDetailKey, "detail") + setString(t, redisServer, otherSessionKey, "99") + setString(t, redisServer, deviceKey, sessionID) + setString(t, redisServer, unrelatedDeviceKey, otherSessionID) + if _, err := redisServer.ZAdd(userSessionsZSet, 1, sessionID); err != nil { + t.Fatalf("seed session zset: %v", err) + } + + if err := clearAllSessions(ctx, svcCtx, userID); err != nil { + t.Fatalf("clearAllSessions() error = %v", err) + } + + assertMissing(t, redisServer, userSessionKey) + assertMissing(t, redisServer, userDetailKey) + assertMissing(t, redisServer, deviceKey) + assertMissing(t, redisServer, userSessionsZSet) + + if !redisServer.Exists(otherSessionKey) { + t.Fatalf("unrelated session %q should remain", otherSessionKey) + } + if !redisServer.Exists(unrelatedDeviceKey) { + t.Fatalf("unrelated device mapping %q should remain", unrelatedDeviceKey) + } +} + +func setString(t *testing.T, server *miniredis.Miniredis, key, value string) { + t.Helper() + if err := server.Set(key, value); err != nil { + t.Fatalf("set %q: %v", key, err) + } +} + +func assertMissing(t *testing.T, server *miniredis.Miniredis, key string) { + t.Helper() + if server.Exists(key) { + t.Fatalf("expected key %q to be removed", key) + } +} diff --git a/internal/logic/admin/user/updateUserBasicInfoLogic.go b/internal/logic/admin/user/updateUserBasicInfoLogic.go index 4fc2a5a..96c6e05 100644 --- a/internal/logic/admin/user/updateUserBasicInfoLogic.go +++ b/internal/logic/admin/user/updateUserBasicInfoLogic.go @@ -44,6 +44,14 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi if req.Avatar != "" && !tool.IsValidImageSize(req.Avatar, 1024) { return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Invalid Image Size") } + if req.Enable != nil && !*req.Enable { + if userInfo.IsAdmin != nil && *userInfo.IsAdmin { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "admin user cannot be disabled") + } + if userInfo.Id == 2 { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "demo user cannot be disabled") + } + } err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { if req.Balance != nil && userInfo.Balance != *req.Balance { @@ -176,6 +184,19 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi } return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Update User Error") } + if req.Enable != nil { + if cacheErr := logicCommon.InvalidateUserEnableCache(l.ctx, l.svcCtx, userInfo.Id); cacheErr != nil { + l.Errorw("[UpdateUserBasicInfoLogic] clear enable cache failed", logger.Field("err", cacheErr.Error()), logger.Field("userId", req.UserId)) + } + if !*req.Enable { + if sessionErr := clearAllSessions(l.ctx, l.svcCtx, userInfo.Id); sessionErr != nil { + l.Errorw("[UpdateUserBasicInfoLogic] clear sessions failed", logger.Field("err", sessionErr.Error()), logger.Field("userId", req.UserId)) + } + for _, device := range userInfo.UserDevices { + l.svcCtx.DeviceManager.KickDevice(userInfo.Id, device.Identifier) + } + } + } return nil } diff --git a/internal/logic/auth/admin/adminLoginLogic.go b/internal/logic/auth/admin/adminLoginLogic.go index 72aed52..06b92bb 100644 --- a/internal/logic/auth/admin/adminLoginLogic.go +++ b/internal/logic/auth/admin/adminLoginLogic.go @@ -7,6 +7,7 @@ import ( "github.com/perfect-panel/server/internal/config" "github.com/perfect-panel/server/internal/logic/auth" + logiccommon "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" @@ -19,6 +20,7 @@ import ( "github.com/perfect-panel/server/pkg/uuidx" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) @@ -94,6 +96,9 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) { return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password") } + if logiccommon.IsUserDisabled(userInfo) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled") + } // Bind device to user if identifier is provided if req.Identifier != "" { @@ -130,6 +135,7 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error()) } + l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId}) loginStatus = true return &types.LoginResponse{ Token: token, diff --git a/internal/logic/auth/emailLoginLogic.go b/internal/logic/auth/emailLoginLogic.go index 3b120d6..24de99e 100644 --- a/internal/logic/auth/emailLoginLogic.go +++ b/internal/logic/auth/emailLoginLogic.go @@ -7,6 +7,7 @@ import ( "time" "github.com/perfect-panel/server/internal/config" + logicCommon "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" @@ -18,6 +19,7 @@ import ( "github.com/perfect-panel/server/pkg/uuidx" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) @@ -114,6 +116,9 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types. return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "register failed: %v", err.Error()) } } + if logicCommon.IsUserDisabled(userInfo) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled") + } // Record login status defer func() { @@ -188,6 +193,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types. if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error()) } + l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId}) loginStatus = true return &types.LoginResponse{ diff --git a/internal/logic/auth/oauth/oAuthLoginGetTokenLogic.go b/internal/logic/auth/oauth/oAuthLoginGetTokenLogic.go index cc6a453..2feb3c4 100644 --- a/internal/logic/auth/oauth/oAuthLoginGetTokenLogic.go +++ b/internal/logic/auth/oauth/oAuthLoginGetTokenLogic.go @@ -9,6 +9,7 @@ import ( "github.com/perfect-panel/server/internal/config" authlogic "github.com/perfect-panel/server/internal/logic/auth" + logiccommon "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/auth" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/user" @@ -23,6 +24,7 @@ import ( "github.com/perfect-panel/server/pkg/uuidx" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) @@ -74,6 +76,9 @@ func (l *OAuthLoginGetTokenLogic) OAuthLoginGetToken(req *types.OAuthLoginGetTok if err != nil { return nil, err } + if logiccommon.IsUserDisabled(userInfo) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled") + } token, err := l.generateToken(userInfo, requestID) if err != nil { @@ -628,6 +633,7 @@ func (l *OAuthLoginGetTokenLogic) generateToken(userInfo *user.User, requestID s ) return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err) } + l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId}) l.Infow("jwt token generated successfully", logger.Field("request_id", requestID), diff --git a/internal/logic/auth/telephoneLoginLogic.go b/internal/logic/auth/telephoneLoginLogic.go index bb538e8..612e5e9 100644 --- a/internal/logic/auth/telephoneLoginLogic.go +++ b/internal/logic/auth/telephoneLoginLogic.go @@ -21,6 +21,7 @@ import ( "github.com/perfect-panel/server/pkg/uuidx" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) @@ -64,6 +65,9 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r } return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error()) } + if common.IsUserDisabled(userInfo) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled") + } // Record login status defer func(svcCtx *svc.ServiceContext) { if userInfo.Id != 0 { @@ -165,6 +169,7 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error()) } + l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId}) loginStatus = true return &types.LoginResponse{ Token: token, diff --git a/internal/logic/auth/userLoginLogic.go b/internal/logic/auth/userLoginLogic.go index 1faa112..1c4c801 100644 --- a/internal/logic/auth/userLoginLogic.go +++ b/internal/logic/auth/userLoginLogic.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + logicCommon "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/pkg/captcha" "github.com/perfect-panel/server/pkg/constant" @@ -17,6 +18,7 @@ import ( "github.com/perfect-panel/server/pkg/uuidx" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" + "github.com/redis/go-redis/v9" "gorm.io/gorm" "github.com/perfect-panel/server/internal/svc" @@ -89,6 +91,9 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) { return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password") } + if logicCommon.IsUserDisabled(userInfo) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled") + } // Update last login time now := time.Now() @@ -135,6 +140,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error()) } + l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId}) loginStatus = true return &types.LoginResponse{ Token: token, diff --git a/internal/logic/auth/userRegisterLogic.go b/internal/logic/auth/userRegisterLogic.go index a5a0bf9..fea394d 100644 --- a/internal/logic/auth/userRegisterLogic.go +++ b/internal/logic/auth/userRegisterLogic.go @@ -101,7 +101,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp * } else if err == nil && !u.DeletedAt.Valid { return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "user email exist: %v", req.Email) } else if err == nil && u.DeletedAt.Valid { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "user email deleted: %v", req.Email) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email deleted: %v", req.Email) } if !registerIpLimit(l.svcCtx, l.ctx, req.IP, "email", req.Email) { diff --git a/internal/logic/common/user_enable.go b/internal/logic/common/user_enable.go new file mode 100644 index 0000000..6fd00d0 --- /dev/null +++ b/internal/logic/common/user_enable.go @@ -0,0 +1,89 @@ +package common + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/perfect-panel/server/internal/config" + modeluser "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "github.com/redis/go-redis/v9" +) + +const userEnableCacheTTL = 30 * time.Second + +func UserEnableCacheKey(userID int64) string { + return fmt.Sprintf("%s%d", config.UserEnableKeyPrefix, userID) +} + +func IsUserDisabled(userInfo *modeluser.User) bool { + return userInfo != nil && userInfo.Enable != nil && !*userInfo.Enable +} + +func ResolveEnabledUser(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) (*modeluser.User, error) { + if userID <= 0 { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid user id: %d", userID) + } + + cacheKey := UserEnableCacheKey(userID) + cached, err := svcCtx.Redis.Get(ctx, cacheKey).Result() + if err == nil { + if cached == strconv.FormatBool(false) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled") + } + return svcCtx.UserModel.FindOne(ctx, userID) + } + if err != nil && err != redis.Nil { + logger.WithContext(ctx).Errorw("get user enable cache failed, fallback to db", + logger.Field("user_id", userID), + logger.Field("error", err.Error()), + ) + return loadEnabledUserFromDB(ctx, svcCtx, userID) + } + + userInfo, err := svcCtx.UserModel.FindOne(ctx, userID) + if err != nil { + return nil, err + } + + if cacheErr := CacheUserEnabled(ctx, svcCtx, userID, !IsUserDisabled(userInfo)); cacheErr != nil { + logger.WithContext(ctx).Errorw("cache user enable state failed", + logger.Field("user_id", userID), + logger.Field("error", cacheErr.Error()), + ) + } + if IsUserDisabled(userInfo) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled") + } + return userInfo, nil +} + +func CacheUserEnabled(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, enabled bool) error { + return svcCtx.Redis.Set(ctx, UserEnableCacheKey(userID), strconv.FormatBool(enabled), userEnableCacheTTL).Err() +} + +func InvalidateUserEnableCache(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) error { + return svcCtx.Redis.Del(ctx, UserEnableCacheKey(userID)).Err() +} + +func loadEnabledUserFromDB(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) (*modeluser.User, error) { + var userInfo modeluser.User + if err := svcCtx.DB.WithContext(ctx). + Model(&modeluser.User{}). + Unscoped(). + Where("`id` = ?", userID). + Preload("UserDevices"). + Preload("AuthMethods"). + First(&userInfo).Error; err != nil { + return nil, err + } + if IsUserDisabled(&userInfo) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled") + } + return &userInfo, nil +} diff --git a/internal/logic/common/user_enable_test.go b/internal/logic/common/user_enable_test.go new file mode 100644 index 0000000..9588f1a --- /dev/null +++ b/internal/logic/common/user_enable_test.go @@ -0,0 +1,266 @@ +package common + +import ( + "context" + "errors" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/alicebob/miniredis/v2" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/redis/go-redis/v9" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestIsUserDisabled(t *testing.T) { + trueValue := true + falseValue := false + + testCases := []struct { + name string + user *user.User + want bool + }{ + { + name: "nil user treated as enabled", + user: nil, + want: false, + }, + { + name: "nil enable treated as enabled", + user: &user.User{Id: 1}, + want: false, + }, + { + name: "enabled user", + user: &user.User{Id: 2, Enable: &trueValue}, + want: false, + }, + { + name: "disabled user", + user: &user.User{Id: 3, Enable: &falseValue}, + want: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got := IsUserDisabled(tc.user); got != tc.want { + t.Fatalf("IsUserDisabled() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestResolveEnabledUser(t *testing.T) { + t.Run("cache hit false returns disabled error without db query", func(t *testing.T) { + svcCtx, mock, redisServer := newEnableTestServiceContext(t) + defer redisServer.Close() + + ctx := context.Background() + if err := svcCtx.Redis.Set(ctx, UserEnableCacheKey(9), "false", 0).Err(); err != nil { + t.Fatalf("seed redis: %v", err) + } + + _, err := ResolveEnabledUser(ctx, svcCtx, 9) + assertCodeError(t, err, xerr.UserDisabled) + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected db query: %v", err) + } + }) + + t.Run("cache miss loads enabled user and backfills cache", func(t *testing.T) { + svcCtx, mock, redisServer := newEnableTestServiceContext(t) + defer redisServer.Close() + + expectFindOne(mock, 11, true) + + ctx := context.Background() + userInfo, err := ResolveEnabledUser(ctx, svcCtx, 11) + if err != nil { + t.Fatalf("ResolveEnabledUser() error = %v", err) + } + if userInfo.Id != 11 { + t.Fatalf("ResolveEnabledUser() user id = %d, want 11", userInfo.Id) + } + + cached, err := svcCtx.Redis.Get(ctx, UserEnableCacheKey(11)).Result() + if err != nil { + t.Fatalf("read backfilled cache: %v", err) + } + if cached != "true" { + t.Fatalf("backfilled cache = %q, want %q", cached, "true") + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("db expectations: %v", err) + } + }) + + t.Run("cache miss loads disabled user and caches false", func(t *testing.T) { + svcCtx, mock, redisServer := newEnableTestServiceContext(t) + defer redisServer.Close() + + expectFindOne(mock, 13, false) + + ctx := context.Background() + _, err := ResolveEnabledUser(ctx, svcCtx, 13) + assertCodeError(t, err, xerr.UserDisabled) + + cached, cacheErr := svcCtx.Redis.Get(ctx, UserEnableCacheKey(13)).Result() + if cacheErr != nil { + t.Fatalf("read disabled cache: %v", cacheErr) + } + if cached != "false" { + t.Fatalf("disabled cache = %q, want %q", cached, "false") + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("db expectations: %v", err) + } + }) + + t.Run("redis failure falls back to db", func(t *testing.T) { + svcCtx, mock, redisServer := newEnableTestServiceContext(t) + expectFindOne(mock, 17, true) + redisServer.Close() + + userInfo, err := ResolveEnabledUser(context.Background(), svcCtx, 17) + if err != nil { + t.Fatalf("ResolveEnabledUser() error = %v", err) + } + if userInfo.Id != 17 { + t.Fatalf("ResolveEnabledUser() user id = %d, want 17", userInfo.Id) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("db expectations: %v", err) + } + }) +} + +func newEnableTestServiceContext(t *testing.T) (*svc.ServiceContext, sqlmock.Sqlmock, *miniredis.Miniredis) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + mock.MatchExpectationsInOrder(false) + t.Cleanup(func() { + _ = sqlDB.Close() + }) + + gdb, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{SingularTable: true}, + }) + if err != nil { + t.Fatalf("gorm.Open() error = %v", err) + } + + redisServer, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis.Run() error = %v", err) + } + + rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + t.Cleanup(func() { + _ = rdb.Close() + }) + + return &svc.ServiceContext{ + DB: gdb, + Redis: rdb, + UserModel: user.NewModel(gdb, rdb), + }, mock, redisServer +} + +func expectFindOne(mock sqlmock.Sqlmock, userID int64, enabled bool) { + rows := sqlmock.NewRows([]string{ + "id", + "password", + "algo", + "salt", + "avatar", + "balance", + "refer_code", + "referer_id", + "commission", + "referral_percentage", + "only_first_purchase", + "gift_amount", + "enable", + "is_admin", + "enable_balance_notify", + "enable_login_notify", + "enable_subscribe_notify", + "enable_trade_notify", + "rules", + "member_status", + "remark", + "last_login_time", + "created_at", + "updated_at", + "deleted_at", + }).AddRow( + userID, + "pwd", + "default", + "", + "", + int64(0), + "", + int64(0), + int64(0), + uint8(0), + true, + int64(0), + enabled, + false, + false, + false, + false, + false, + "", + "", + "", + nil, + nil, + nil, + nil, + ) + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user` WHERE `id` = ? ORDER BY `user`.`id` LIMIT ?")). + WithArgs(userID, 1). + WillReturnRows(rows) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_device` WHERE `user_device`.`user_id` = ?")). + WithArgs(userID). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "identifier"})) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_auth_methods` WHERE `user_auth_methods`.`user_id` = ?")). + WithArgs(userID). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "auth_type", "auth_identifier", "verified"})) +} + +func assertCodeError(t *testing.T, err error, wantCode uint32) { + t.Helper() + + if err == nil { + t.Fatal("expected error, got nil") + } + + var codeErr *xerr.CodeError + if !errors.As(err, &codeErr) { + t.Fatalf("error %T does not contain xerr.CodeError: %v", err, err) + } + if codeErr.GetErrCode() != wantCode { + t.Fatalf("error code = %d, want %d", codeErr.GetErrCode(), wantCode) + } +} diff --git a/internal/logic/subscribe/subscribeLogic.go b/internal/logic/subscribe/subscribeLogic.go index 7b88160..3907b71 100644 --- a/internal/logic/subscribe/subscribeLogic.go +++ b/internal/logic/subscribe/subscribeLogic.go @@ -7,6 +7,7 @@ import ( "time" "github.com/perfect-panel/server/adapter" + logiccommon "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/client" "github.com/perfect-panel/server/internal/model/group" "github.com/perfect-panel/server/internal/model/log" @@ -79,6 +80,10 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc l.Errorw("[SubscribeLogic] Get user subscribe failed", logger.Field("error", err.Error()), logger.Field("token", req.Token)) return nil, err } + if _, err := logiccommon.ResolveEnabledUser(l.ctx.Request.Context(), l.svc, userSubscribe.UserId); err != nil { + l.Errorw("[SubscribeLogic] User disabled", logger.Field("error", err.Error()), logger.Field("userId", userSubscribe.UserId)) + return nil, err + } var subscribeStatus = false defer func() { diff --git a/internal/middleware/authMiddleware.go b/internal/middleware/authMiddleware.go index 617d2ec..49710c4 100644 --- a/internal/middleware/authMiddleware.go +++ b/internal/middleware/authMiddleware.go @@ -6,6 +6,7 @@ import ( "strings" "time" + logiccommon "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/logger" @@ -87,10 +88,10 @@ func authenticateRequest(c *gin.Context, svc *svc.ServiceContext, token string, svc.Redis.Expire(c, sessionIdCacheKey, time.Duration(svc.Config.JwtAuth.AccessExpire)*time.Second) - userInfo, err := svc.UserModel.FindOne(c, userId) + userInfo, err := logiccommon.ResolveEnabledUser(c, svc, userId) if err != nil { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] UserModel FindOne", logger.Field("error", err.Error()), logger.Field("userId", userId)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Database Query Error")) + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] ResolveEnabledUser", logger.Field("error", err.Error()), logger.Field("userId", userId)) + result.HttpResult(c, nil, err) c.Abort() return false }