This commit is contained in:
shanshanzhong 2026-01-09 00:28:37 -08:00
parent 16b4300354
commit 93c4d7b7d1
8 changed files with 174 additions and 56 deletions

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

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

@ -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

@ -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)