Compare commits

...

2 Commits

Author SHA1 Message Date
7b33ab6e2a jwt注销问题
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 5m54s
2026-01-09 00:36:19 -08:00
93c4d7b7d1 back 2026-01-09 00:28:37 -08:00
15 changed files with 292 additions and 106 deletions

View File

@ -51,6 +51,7 @@ type (
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
} }
EmailLoginRequest { EmailLoginRequest {
Identifier string `json:"identifier"`
Email string `json:"email" validate:"required"` Email string `json:"email" validate:"required"`
Code string `json:"code" validate:"required"` Code string `json:"code" validate:"required"`
Invite string `json:"invite,optional"` Invite string `json:"invite,optional"`

View File

@ -0,0 +1,113 @@
# 项目加解密使用说明
本指南介绍了 PPanel Server 项目中使用的加解密机制主要用于设备端Device通信的安全保障。
## 1. 核心算法
项目使用 **AES-256-CBC** 加密算法。
- **填充方式**PKCS7 Padding。
- **数据编码**Base64。
## 2. 密钥Key与初始化向量IV生成逻辑
### 2.1 密钥生成 (Key Generation)
密钥由一个预定义的 `SecuritySecret`(简称 Secret生成
1. 对 Secret 进行 **SHA-256** 哈希。
2. 取哈希结果的前 **32 字节** 作为 AES-256 的密钥。
### 2.2 初始化向量生成 (IV Generation)
IV 是动态生成的,以增强安全性:
1. 客户端或服务端生成一个随机字符串Nonce通常是纳秒级时间戳
2. 对 Nonce 进行 **MD5** 哈希。
3. 将 MD5 结果(十六进制字符串)与 Secret 拼接。
4. 对拼接后的字符串按 2.1 节的方式生成密钥逻辑处理,取结果的前 **16 字节** 作为 IV。
> [!NOTE]
> 在 API 通信中Nonce 字符串通常通过请求参数中的 `time` 字段传递。
## 3. 身份识别与优先顺序
服务端通过 `Login-Type` 来识别是否需要进行加解密逻辑(值为 `device` 时触发)。
### 3.1 识别途径
1. **Token 负载 (JWT Claims)**Token 中包含 `LoginType` (值为 `device`) 和 `DeviceId`
2. **请求头 (Header)**`Login-Type: device`
### 3.2 优先顺序与场景
- **已登录场景**:服务端优先从 **Token** 负载中读取 `LoginType`。如果 Token 合法且包含 `LoginType: device`,则启用加解密。
- **未登录/登录中场景**:例如 `/v1/auth/login/device` 接口,由于此时没有有效 Token服务端会检查 **Header** 中的 `Login-Type`
> [!TIP]
> 为了确保一致性,建议在设备端请求中**始终**携带 `Login-Type: device` 请求头,并在登录后确保存储的 Token 负载中也包含对应信息。
## 4. 中间件应用 (DeviceMiddleware)
`DeviceMiddleware` 处理 `Login-Type: device` 的请求:
- **请求解密**
- 检查 URL 参数或 JSON Body 中的 `data`(加密数据)和 `time`Nonce
- 使用配置的 Secret 和 Nonce 解密 `data`
- 将解密后的 JSON 重新注入到请求上下文中。
- **响应加密**
- 拦截响应 Body。
- 加密 Body 中的 `data` 字段。
- 将响应格式化为:
```json
{
"data": "ENCRYPTED_BASE64_STRING",
"time": "NONCE_STRING"
}
```
## 5. Token 负载详情 (JWT Payload)
`Login-Type``device`JWT Token 会包含以下自定义字段:
- `LoginType`: `"device"`
- `DeviceId`: 设备的数据库唯一 ID。
## 6. 代码示例
### Go 语言 (服务端)
参考 [pkg/aes/aes.go](file:///Users/Apple/vpn/ppanel-server/pkg/aes/aes.go)
```go
import pkgaes "github.com/perfect-panel/server/pkg/aes"
// 加密
encrypt, nonce, err := pkgaes.Encrypt([]byte("plain text"), secret)
// 解密
decrypt, err := pkgaes.Decrypt(cipherText, secret, nonce)
```
### JavaScript (客户端示例)
使用 `crypto-js` 库:
```javascript
const CryptoJS = require("crypto-js");
function getIv(nonce, secret) {
const md5Nonce = CryptoJS.MD5(nonce).toString();
const ivStr = md5Nonce + secret;
const key = CryptoJS.SHA256(ivStr);
return CryptoJS.enc.Hex.parse(key.toString().substring(0, 32));
}
function getKey(secret) {
const key = CryptoJS.SHA256(secret);
return CryptoJS.enc.Hex.parse(key.toString().substring(0, 64));
}
// 加密示例
const key = getKey(secret);
const iv = getIv(nonce, secret);
const encrypted = CryptoJS.AES.encrypt("plain text", key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
console.log(encrypted.toString()); // Base64
```
## 5. 安全建议
- 请务必在配置文件中修改默认的 `SecuritySecret`
- 确保 `time` (Nonce) 在每次请求时都是唯一的,以防止重放攻击和频率分析。

View File

@ -15,6 +15,8 @@
- 上报逻辑:`internal/logic/common/logMessageReportLogic.go`(限流、指纹去重、入库)。 - 上报逻辑:`internal/logic/common/logMessageReportLogic.go`(限流、指纹去重、入库)。
- 管理查询:`internal/logic/admin/log/getErrorLogMessageListLogic.go``getErrorLogMessageDetailLogic.go` - 管理查询:`internal/logic/admin/log/getErrorLogMessageListLogic.go``getErrorLogMessageDetailLogic.go`
- 类型:`internal/types/types.go` 新增请求/响应结构。 - 类型:`internal/types/types.go` 新增请求/响应结构。
- 安全:详细加解密逻辑见 [加解密说明文档.md](file:///Users/Apple/vpn/ppanel-server/doc/加解密说明文档.md)。
## 进度记录 ## 进度记录
- 2025-12-02 - 2025-12-02
@ -23,6 +25,9 @@
- 完成公共上报接口与限流、去重逻辑;编译验证通过。 - 完成公共上报接口与限流、去重逻辑;编译验证通过。
- 完成管理端列表与详情接口;编译验证通过。 - 完成管理端列表与详情接口;编译验证通过。
- 待办:根据运营需求调整限流阈值与日志保留策略。 - 待办:根据运营需求调整限流阈值与日志保留策略。
- 2026-01-08
- 完成「项目加解密使用说明」文档编写,涵盖 AES-256-CBC 实现及中间件逻辑。
## 接口规范 ## 接口规范
- 上报:`POST /v1/common/log/message/report`(详见 `doc/api/log_message_report.md` - 上报:`POST /v1/common/log/message/report`(详见 `doc/api/log_message_report.md`

View File

@ -73,7 +73,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
if err := json.Unmarshal([]byte(value), &payload); err != nil { if err := json.Unmarshal([]byte(value), &payload); err != nil {
continue continue
} }
if payload.Code == req.Code { if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= 600 {
verified = true verified = true
cacheKeyUsed = cacheKey cacheKeyUsed = cacheKey
break break
@ -200,6 +200,28 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
) )
} }
// Bind device to user if identifier is provided
var deviceId int64
if req.Identifier != "" {
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
var ce *xerr.CodeError
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
return nil, ce
}
l.Errorw("failed to bind device to user",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
logger.Field("error", err.Error()),
)
} else {
// Query device info to get DeviceId
if device, dErr := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier); dErr == nil {
deviceId = device.Id
}
}
}
// Login (Generate Token) // Login (Generate Token)
if l.ctx.Value(constant.LoginType) != nil { if l.ctx.Value(constant.LoginType) != nil {
req.LoginType = l.ctx.Value(constant.LoginType).(string) req.LoginType = l.ctx.Value(constant.LoginType).(string)
@ -213,6 +235,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
jwt.WithOption("UserId", userInfo.Id), jwt.WithOption("UserId", userInfo.Id),
jwt.WithOption("SessionId", sessionId), jwt.WithOption("SessionId", sessionId),
jwt.WithOption("LoginType", req.LoginType), jwt.WithOption("LoginType", req.LoginType),
jwt.WithOption("DeviceId", deviceId),
) )
if err != nil { if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error()) return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())

View File

@ -79,10 +79,11 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
l.Errorw("Unmarshal errors", logger.Field("cacheKey", cacheKey), logger.Field("error", err.Error()), logger.Field("value", value)) l.Errorw("Unmarshal errors", logger.Field("cacheKey", cacheKey), logger.Field("error", err.Error()), logger.Field("value", value))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error") return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error")
} }
if payload.Code != req.Code { if payload.Code != req.Code || time.Now().Unix()-payload.LastAt > 600 {
l.Errorw("Verification code error", logger.Field("cacheKey", cacheKey), logger.Field("error", "Verification code error"), logger.Field("reqCode", req.Code), logger.Field("payloadCode", payload.Code)) l.Errorw("Verification code error", logger.Field("cacheKey", cacheKey), logger.Field("error", "Verification code error or expired"), logger.Field("reqCode", req.Code), logger.Field("payloadCode", payload.Code))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error") return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error or expired")
} }
l.svcCtx.Redis.Del(l.ctx, cacheKey)
} }
// Check user // Check user
@ -110,20 +111,20 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
} }
// Bind device to user if identifier is provided // Bind device to user if identifier is provided
if req.Identifier != "" { if req.Identifier != "" {
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx) bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil { if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
var ce *xerr.CodeError var ce *xerr.CodeError
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded { if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
return nil, ce return nil, ce
} }
l.Errorw("failed to bind device to user", l.Errorw("failed to bind device to user",
logger.Field("user_id", userInfo.Id), logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier), logger.Field("identifier", req.Identifier),
logger.Field("error", err.Error()), logger.Field("error", err.Error()),
) )
} }
} }
if l.ctx.Value(constant.LoginType) != nil { if l.ctx.Value(constant.LoginType) != nil {
req.LoginType = l.ctx.Value(constant.LoginType).(string) req.LoginType = l.ctx.Value(constant.LoginType).(string)
} }

View File

@ -92,6 +92,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
} }
// Bind device to user if identifier is provided // Bind device to user if identifier is provided
var deviceId int64
if req.Identifier != "" { if req.Identifier != "" {
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx) bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil { if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
@ -104,6 +105,11 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
logger.Field("identifier", req.Identifier), logger.Field("identifier", req.Identifier),
logger.Field("error", err.Error()), logger.Field("error", err.Error()),
) )
} else {
// Query device info to get DeviceId
if device, dErr := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier); dErr == nil {
deviceId = device.Id
}
} }
} }
if l.ctx.Value(constant.LoginType) != nil { if l.ctx.Value(constant.LoginType) != nil {
@ -119,6 +125,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
jwt.WithOption("UserId", userInfo.Id), jwt.WithOption("UserId", userInfo.Id),
jwt.WithOption("SessionId", sessionId), jwt.WithOption("SessionId", sessionId),
jwt.WithOption("LoginType", req.LoginType), jwt.WithOption("LoginType", req.LoginType),
jwt.WithOption("DeviceId", deviceId),
) )
if err != nil { if err != nil {
l.Logger.Error("[UserLogin] token generate error", logger.Field("error", err.Error())) l.Logger.Error("[UserLogin] token generate error", logger.Field("error", err.Error()))

View File

@ -74,9 +74,10 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
l.Errorw("Unmarshal Error", logger.Field("error", err.Error()), logger.Field("value", value)) l.Errorw("Unmarshal Error", logger.Field("error", err.Error()), logger.Field("value", value))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
} }
if payload.Code != req.Code { if payload.Code != req.Code || time.Now().Unix()-payload.LastAt > 600 {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired")
} }
l.svcCtx.Redis.Del(l.ctx, cacheKey)
} }
// Check if the user exists // Check if the user exists
_, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email) _, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
@ -127,20 +128,20 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
return nil return nil
}) })
// Bind device to user if identifier is provided // Bind device to user if identifier is provided
if req.Identifier != "" { if req.Identifier != "" {
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx) bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil { if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
var ce *xerr.CodeError var ce *xerr.CodeError
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded { if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
return nil, ce return nil, ce
} }
l.Errorw("failed to bind device to user", l.Errorw("failed to bind device to user",
logger.Field("user_id", userInfo.Id), logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier), logger.Field("identifier", req.Identifier),
logger.Field("error", err.Error()), logger.Field("error", err.Error()),
) )
} }
} }
if l.ctx.Value(constant.LoginType) != nil { if l.ctx.Value(constant.LoginType) != nil {
req.LoginType = l.ctx.Value(constant.LoginType).(string) req.LoginType = l.ctx.Value(constant.LoginType).(string)
} }

View File

@ -101,7 +101,7 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
} }
// Marshal the payload // Marshal the payload
val, _ := json.Marshal(payload) val, _ := json.Marshal(payload)
if err = l.svcCtx.Redis.Set(l.ctx, cacheKey, string(val), time.Second*IntervalTime*5).Err(); err != nil { if err = l.svcCtx.Redis.Set(l.ctx, cacheKey, string(val), time.Minute*10).Err(); err != nil {
l.Errorw("[SendEmailCode]: Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey)) l.Errorw("[SendEmailCode]: Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey))
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to set verification code") return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to set verification code")
} }

View File

@ -56,28 +56,25 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
scenes = []string{constant.Security.String(), constant.Register.String()} scenes = []string{constant.Security.String(), constant.Register.String()}
verified = false verified = false
) )
if req.Code == "202511" { for _, scene := range scenes {
verified = true cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
} else { value, getErr := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
for _, scene := range scenes { if getErr != nil || value == "" {
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email) continue
value, getErr := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result() }
if getErr != nil || value == "" { var p payload
continue if err := json.Unmarshal([]byte(value), &p); err != nil {
} continue
var p payload }
if err := json.Unmarshal([]byte(value), &p); err != nil { // 校验验证码及有效期10分钟
continue if p.Code == req.Code && time.Now().Unix()-p.LastAt <= 600 {
} _ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err()
if p.Code == req.Code { verified = true
_ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err() break
verified = true
break
}
} }
} }
if !verified { if !verified {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired")
} }
// 获取当前用户的设备标识符 // 获取当前用户的设备标识符

View File

@ -33,11 +33,7 @@ func NewDeleteAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Del
} }
} }
// DeleteAccount 注销账号逻辑 // DeleteAccount 注销当前设备账号逻辑 (改为精准解绑)
// 1. 获取当前用户信息
// 2. 删除所有关联数据(用户、认证方式、设备)
// 3. 根据原设备信息创建全新账号
// 4. 返回新账号信息
func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, err error) { func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, err error) {
// 获取当前用户 // 获取当前用户
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
@ -45,48 +41,74 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse,
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
} }
// 获取当前调用设备 ID
currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64)
resp = &types.DeleteAccountResponse{} resp = &types.DeleteAccountResponse{}
var newUserId int64 var newUserId int64
// 如果没有识别到设备 ID (可能是旧版 Token),则执行安全注销:仅清除 Session
if currentDeviceId == 0 {
l.Infow("未识别到设备 ID仅清理当前会话", logger.Field("user_id", currentUser.Id))
l.clearCurrentSession(currentUser.Id)
resp.Success = true
resp.Message = "会话已清除"
return resp, nil
}
// 开始数据库事务 // 开始数据库事务
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
// 1. 查找用户的所有设备(用于后续创建新账号) // 1. 查找当前设备
devices, _, err := l.svcCtx.UserModel.QueryDeviceList(l.ctx, currentUser.Id) var currentDevice user.Device
if err != nil { if err := tx.Where("id = ? AND user_id = ?", currentDeviceId, currentUser.Id).First(&currentDevice).Error; err != nil {
return errors.Wrap(err, "查询用户设备失败") l.Infow("当前请求设备记录不存在或归属不匹配", logger.Field("device_id", currentDeviceId), logger.Field("error", err.Error()))
return nil // 不抛错,直接走清理 Session 流程
} }
// 2. 删除用户的所有认证方式 // 2. 检查用户是否有其他认证方式 (如邮箱) 或 其他设备
err = tx.Model(&user.AuthMethods{}).Where("`user_id` = ?", currentUser.Id).Delete(&user.AuthMethods{}).Error var authMethodsCount int64
if err != nil { tx.Model(&user.AuthMethods{}).Where("user_id = ?", currentUser.Id).Count(&authMethodsCount)
return errors.Wrap(err, "删除用户认证方式失败")
}
// 3. 删除用户的所有设备 var devicesCount int64
err = tx.Model(&user.Device{}).Where("`user_id` = ?", currentUser.Id).Delete(&user.Device{}).Error tx.Model(&user.Device{}).Where("user_id = ?", currentUser.Id).Count(&devicesCount)
if err != nil {
return errors.Wrap(err, "删除用户设备失败")
}
// 4. 删除用户的订阅信息 // 判定是否是主账号解绑:如果除了当前设备外,还有邮箱或其他设备,则只解绑当前设备
err = tx.Model(&user.Subscribe{}).Where("`user_id` = ?", currentUser.Id).Delete(&user.Subscribe{}).Error isMainAccount := authMethodsCount > 1 || devicesCount > 1
if err != nil {
return errors.Wrap(err, "删除用户订阅信息失败")
}
// 5. 删除用户本身 if isMainAccount {
err = tx.Model(&user.User{}).Where("`id` = ?", currentUser.Id).Delete(&user.User{}).Error l.Infow("主账号解绑,仅迁移当前设备", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
if err != nil {
return errors.Wrap(err, "删除用户失败")
}
// 7. 为每个原设备创建新的用户(使用同一事务) // 为当前设备创建新用户并迁移
for _, oldDevice := range devices { newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
userInfo, err := l.registerUserAndDevice(tx, oldDevice.Identifier, oldDevice.Ip, oldDevice.UserAgent)
if err != nil { if err != nil {
return errors.Wrap(err, "创建新用户失败") return err
} }
newUserId = userInfo.Id // 保留最后一个新用户ID newUserId = newUser.Id
// 从原用户删除当前设备的认证方式 (按 Identifier 准确删除)
if err := tx.Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", currentUser.Id, "device", currentDevice.Identifier).Delete(&user.AuthMethods{}).Error; err != nil {
return errors.Wrap(err, "删除原设备认证失败")
}
// 从原用户删除当前设备记录
if err := tx.Where("id = ?", currentDeviceId).Delete(&user.Device{}).Error; err != nil {
return errors.Wrap(err, "删除原设备记录失败")
}
} else {
l.Infow("纯设备账号注销,执行物理删除并重置", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
// 完全删除原用户相关资产
tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{})
tx.Where("user_id = ?", currentUser.Id).Delete(&user.Device{})
tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{})
tx.Delete(&user.User{}, currentUser.Id)
// 重新注册一个新用户
newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
if err != nil {
return err
}
newUserId = newUser.Id
} }
return nil return nil
@ -96,23 +118,28 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse,
return nil, err return nil, err
} }
// 注销当前会话token删除Redis中的会话标记并从用户会话集合移除 // 最终清理当前 Session
if sessionId, ok := l.ctx.Value(constant.CtxKeySessionID).(string); ok && sessionId != "" { l.clearCurrentSession(currentUser.Id)
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
_ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err()
// 从用户会话集合中移除当前session避免残留
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, currentUser.Id)
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, sessionId).Err()
}
resp.Success = true resp.Success = true
resp.Message = "账户注销成功" resp.Message = "注销成功"
resp.UserId = newUserId resp.UserId = newUserId
resp.Code = 200 resp.Code = 200
return resp, nil return resp, nil
} }
// clearCurrentSession 清理当前请求的会话
func (l *DeleteAccountLogic) clearCurrentSession(userId int64) {
if sessionId, ok := l.ctx.Value(constant.CtxKeySessionID).(string); ok && sessionId != "" {
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
_ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err()
// 从用户会话集合中移除当前session
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, sessionId).Err()
}
}
// generateReferCode 生成推荐码 // generateReferCode 生成推荐码
func generateReferCode() string { func generateReferCode() string {
bytes := make([]byte, 4) bytes := make([]byte, 4)

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
"github.com/perfect-panel/server/internal/config" "github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/model/user"
@ -49,8 +50,8 @@ func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
l.Errorw("Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey)) l.Errorw("Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey))
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
} }
if payload.Code != req.Code { if payload.Code != req.Code || time.Now().Unix()-payload.LastAt > 600 {
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired")
} }
l.svcCtx.Redis.Del(l.ctx, cacheKey) l.svcCtx.Redis.Del(l.ctx, cacheKey)

View File

@ -50,6 +50,11 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
userId := int64(claims["UserId"].(float64)) userId := int64(claims["UserId"].(float64))
// get session id from token // get session id from token
sessionId := claims["SessionId"].(string) sessionId := claims["SessionId"].(string)
// get device id from token
var deviceId int64
if claims["DeviceId"] != nil {
deviceId = int64(claims["DeviceId"].(float64))
}
// get session id from redis // get session id from redis
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
value, err := svc.Redis.Get(c, sessionIdCacheKey).Result() value, err := svc.Redis.Get(c, sessionIdCacheKey).Result()
@ -91,6 +96,9 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
ctx = context.WithValue(ctx, constant.LoginType, loginType) ctx = context.WithValue(ctx, constant.LoginType, loginType)
ctx = context.WithValue(ctx, constant.CtxKeyUser, userInfo) ctx = context.WithValue(ctx, constant.CtxKeyUser, userInfo)
ctx = context.WithValue(ctx, constant.CtxKeySessionID, sessionId) ctx = context.WithValue(ctx, constant.CtxKeySessionID, sessionId)
if deviceId > 0 {
ctx = context.WithValue(ctx, constant.CtxKeyDeviceID, deviceId)
}
c.Request = c.Request.WithContext(ctx) c.Request = c.Request.WithContext(ctx)
c.Next() c.Next()
} }

View File

@ -596,13 +596,14 @@ type EmailAuthticateConfig struct {
} }
type EmailLoginRequest struct { type EmailLoginRequest struct {
Email string `json:"email" validate:"required"` Identifier string `json:"identifier"`
Code string `json:"code" validate:"required"` Email string `json:"email" validate:"required"`
Invite string `json:"invite,optional"` Code string `json:"code" validate:"required"`
IP string `header:"X-Original-Forwarded-For"` Invite string `json:"invite,optional"`
UserAgent string `header:"User-Agent"` IP string `header:"X-Original-Forwarded-For"`
LoginType string `header:"Login-Type"` UserAgent string `header:"User-Agent"`
CfToken string `json:"cf_token,optional"` LoginType string `header:"Login-Type"`
CfToken string `json:"cf_token,optional"`
} }
type FilterBalanceLogRequest struct { type FilterBalanceLogRequest struct {

View File

@ -9,5 +9,6 @@ const (
CtxKeyPlatform CtxKey = "platform" CtxKeyPlatform CtxKey = "platform"
CtxKeyPayment CtxKey = "payment" CtxKeyPayment CtxKey = "payment"
LoginType CtxKey = "loginType" LoginType CtxKey = "loginType"
CtxKeyDeviceID CtxKey = "deviceId"
CtxKeyIncludeExpired CtxKey = "includeExpired" CtxKeyIncludeExpired CtxKey = "includeExpired"
) )

BIN
server Executable file

Binary file not shown.