diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 60236ee..1024e64 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -172,6 +172,9 @@ jobs: runs-on: zero-ppanel-server container: # <-- 整个 build job 在 Node.js 容器中运行 image: node:20.15.1 + volumes: + - /root/go/pkg/mod:/root/go/pkg/mod + - /root/.cache/go-build:/root/.cache/go-build needs: prepare if: needs.prepare.outputs.has_changes == 'true' && contains(needs.prepare.outputs.changed_services, matrix.service.name) strategy: @@ -206,16 +209,6 @@ jobs: with: go-version: '1.24.0' # 确保使用 go.mod 中指定的精确版本 - - name: 🗄️ 缓存 Go modules - uses: actions/cache@v3 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - name: 🔧 确保 Docker CLI 可用并初始化 Go Modules run: | set -e diff --git a/.gitignore b/.gitignore index 9cc96eb..e260349 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ logs/ etc/*.local.yaml apps/*/etc/*.local.yaml deploy/.env +goctl_tpl/ # IDE .idea/ diff --git a/apps/api/etc/api-dev.yaml b/apps/api/etc/api-dev.yaml index f90f60d..affe49c 100644 --- a/apps/api/etc/api-dev.yaml +++ b/apps/api/etc/api-dev.yaml @@ -49,6 +49,9 @@ Security: Enable: true SecuritySecret: "uB4G,XxL2{7b" +Device: + Enable: true + Asynq: Addr: 127.0.0.1:6379 diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 862b3ce..40990ae 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -23,4 +23,7 @@ type Config struct { Enable bool SecuritySecret string } + Device struct { + Enable bool + } } diff --git a/apps/api/internal/logic/auth/deviceLoginLogic.go b/apps/api/internal/logic/auth/deviceLoginLogic.go index 7658e24..45487ae 100644 --- a/apps/api/internal/logic/auth/deviceLoginLogic.go +++ b/apps/api/internal/logic/auth/deviceLoginLogic.go @@ -34,6 +34,11 @@ func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Devic } func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginReq) (resp *types.LoginResp, err error) { + // todo: 新建设备登录是否禁用逻辑 + if !l.svcCtx.Config.Device.Enable { + return nil, xerr.NewErrCode(xerr.DeviceLoginDisabled) + } + // 1. 调用 Core RPC 设备登录 rpcResp, err := l.svcCtx.CoreRpc.DeviceLogin(l.ctx, &core.DeviceLoginReq{ Identifier: req.Identifier, diff --git a/apps/api/internal/middleware/decryptMiddleware.go b/apps/api/internal/middleware/decryptMiddleware.go index 973bb77..d2fcc55 100644 --- a/apps/api/internal/middleware/decryptMiddleware.go +++ b/apps/api/internal/middleware/decryptMiddleware.go @@ -8,6 +8,7 @@ import ( "io" "net" "net/http" + "net/url" "strings" "github.com/zero-ppanel/zero-ppanel/apps/api/internal/config" @@ -26,6 +27,8 @@ func NewDecryptMiddleware(c config.Config) *DecryptMiddleware { func (m *DecryptMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + fmt.Printf("[DEBUG] DecryptMiddleware entered, Security.Enable=%v, Login-Type=%q\n", + m.conf.Security.Enable, r.Header.Get("Login-Type")) if !m.conf.Security.Enable { next(w, r) return @@ -86,7 +89,12 @@ func (m *DecryptMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { httpx.Error(w, xerr.NewErrCode(xerr.DecryptFailed)) return } + fmt.Printf("[DEBUG] decrypted body: %s\n", string(plain)) r.Body = io.NopCloser(bytes.NewBuffer(plain)) + // 防止 httpx.Parse 内部的 r.ParseForm() 消费已替换的 body: + // ParseForm 首行检查 r.PostForm == nil,置空后它不会再读 body。 + r.PostForm = url.Values{} + r.ContentLength = int64(len(plain)) } next(rw, r) diff --git a/test_device_login.mjs b/test_device_login.mjs new file mode 100644 index 0000000..732bf37 --- /dev/null +++ b/test_device_login.mjs @@ -0,0 +1,148 @@ +/** + * 设备登录接口测试脚本 + * + * 复现服务端的加密与签名逻辑: + * - AES-CBC 加密: key=SHA256(secret)[:32], iv=SHA256(md5hex(nonce)+secret)[:16] + * - 签名: HMAC-SHA256(appSecret, METHOD\nPATH\nQUERY\nBODY_SHA256\nAppId\nTimestamp\nNonce) + */ + +import crypto from 'node:crypto'; +import https from 'node:https'; +import http from 'node:http'; + +// ─── 配置(对应 api-dev.yaml)───────────────────────────────────────────────── +const CONFIG = { + baseUrl: 'http://127.0.0.1:8080', // 本地测试改这里;生产改为 https://tapi.hifast.biz + appId: 'android-client', + appSecret: 'uB4G,XxL2{7b}', // AppSignature.AppSecrets[appId] + securitySecret: 'uB4G,XxL2{7b', // Security.SecuritySecret(注意少一个 }) +}; + +// ─── AES-CBC 加密工具(与 pkg/cryptox/aes.go 对齐)───────────────────────────── + +function generateKey(secret) { + return crypto.createHash('sha256').update(secret).digest().slice(0, 32); +} + +function generateIv(nonce, secret) { + const md5hex = crypto.createHash('md5').update(nonce).digest('hex'); + return crypto.createHash('sha256').update(md5hex + secret).digest().slice(0, 16); +} + +function aesEncrypt(plaintext, secret) { + const nonce = crypto.randomBytes(8).toString('hex'); // 16位 hex,对应 Go 的 fmt.Sprintf("%x", UnixNano) + const key = generateKey(secret); + const iv = generateIv(nonce, secret); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return { + data: encrypted.toString('base64'), + time: nonce, + }; +} + +// ─── 签名工具(与 pkg/signature/canonical.go 对齐)──────────────────────────── + +function sha256Hex(data) { + return crypto.createHash('sha256').update(data).digest('hex'); +} + +function buildStringToSign(method, path, rawQuery, bodyBytes, appId, timestamp, nonce) { + // 规范化 query(按 key 字母排序) + let canonical = ''; + if (rawQuery) { + const params = new URLSearchParams(rawQuery); + const sorted = [...params.entries()].sort((a, b) => a[0].localeCompare(b[0])); + canonical = sorted.map(([k, v]) => `${k}=${v}`).join('&'); + } + const bodyHash = sha256Hex(bodyBytes); + return [method.toUpperCase(), path, canonical, bodyHash, appId, timestamp, nonce].join('\n'); +} + +function hmacSha256(secret, data) { + return crypto.createHmac('sha256', secret).update(data).digest('hex'); +} + +// ─── 发送请求 ───────────────────────────────────────────────────────────────── + +async function request(method, path, payload) { + const url = new URL(CONFIG.baseUrl + path); + + const timestamp = String(Math.floor(Date.now() / 1000)); + const nonce = crypto.randomBytes(16).toString('hex'); + + // 1. AES 加密 body + const { data: encData, time: encTime } = aesEncrypt(JSON.stringify(payload), CONFIG.securitySecret); + const bodyObj = { data: encData, time: encTime }; + const bodyStr = JSON.stringify(bodyObj); + const bodyBytes = Buffer.from(bodyStr, 'utf8'); + + // 2. 构造签名 + const sts = buildStringToSign(method, url.pathname, url.search.slice(1), bodyBytes, CONFIG.appId, timestamp, nonce); + const sig = hmacSha256(CONFIG.appSecret, sts); + + const headers = { + 'Content-Type': 'application/json', + 'X-App-Id': CONFIG.appId, + 'X-Timestamp': timestamp, + 'X-Nonce': nonce, + 'X-Signature': sig, + 'Login-Type': 'device', + }; + + console.log('\n📤 请求信息'); + console.log(' URL:', url.toString()); + console.log(' timestamp:', timestamp); + console.log(' nonce:', nonce); + console.log(' signature:', sig); + console.log(' 加密 nonce(time):', encTime); + console.log(' 签名原文:\n' + sts.split('\n').map(l => ' ' + l).join('\n')); + console.log(' body:', bodyStr); + + return new Promise((resolve, reject) => { + const lib = url.protocol === 'https:' ? https : http; + const req = lib.request(url, { method, headers }, (res) => { + let raw = ''; + res.on('data', chunk => raw += chunk); + res.on('end', () => { + console.log('\n📥 响应'); + console.log(' status:', res.statusCode); + try { + console.log(' body:', JSON.stringify(JSON.parse(raw), null, 2)); + } catch { + console.log(' body:', raw); + } + resolve({ status: res.statusCode, body: raw }); + }); + }); + req.on('error', reject); + req.write(bodyStr); + req.end(); + }); +} + +// ─── 测试用例 ────────────────────────────────────────────────────────────────── + +async function main() { + console.log('='.repeat(60)); + console.log('🔐 设备登录接口测试'); + console.log('='.repeat(60)); + + // 测试 1:正常设备登录 + console.log('\n【TEST 1】正常设备登录'); + await request('POST', '/api/v1/auth/login/device', { + identifier: 'test-device-001', + user_agent: 'TestClient/1.0 (Node.js test)', + short_code: '', + cf_token: '', + }); + + // 测试 2:不同 identifier(新设备,应该自动注册) + console.log('\n【TEST 2】新设备首次登录'); + await request('POST', '/api/v1/auth/login/device', { + identifier: `device-${Date.now()}`, + user_agent: 'TestClient/1.0 (Node.js test)', + }); +} + +main().catch(console.error);