feat: 新增设备登录开关配置,优化 CI Go 模块缓存机制,并添加解密中间件调试日志。
Some checks failed
Build docker and publish / prepare (20.15.1) (push) Successful in 10s
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.admin image_name:ppanel-admin name:admin]) (push) Successful in 1m16s
Build docker and publish / deploy (push) Has been cancelled
Build docker and publish / notify (push) Has been cancelled
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.rpc-core image_name:ppanel-rpc-core name:rpc-core]) (push) Has been cancelled
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.scheduler image_name:ppanel-scheduler name:scheduler]) (push) Has been cancelled
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.node image_name:ppanel-node name:node]) (push) Has been cancelled
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.queue image_name:ppanel-queue name:queue]) (push) Has been cancelled
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.api image_name:ppanel-api name:api]) (push) Has been cancelled
Some checks failed
Build docker and publish / prepare (20.15.1) (push) Successful in 10s
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.admin image_name:ppanel-admin name:admin]) (push) Successful in 1m16s
Build docker and publish / deploy (push) Has been cancelled
Build docker and publish / notify (push) Has been cancelled
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.rpc-core image_name:ppanel-rpc-core name:rpc-core]) (push) Has been cancelled
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.scheduler image_name:ppanel-scheduler name:scheduler]) (push) Has been cancelled
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.node image_name:ppanel-node name:node]) (push) Has been cancelled
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.queue image_name:ppanel-queue name:queue]) (push) Has been cancelled
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.api image_name:ppanel-api name:api]) (push) Has been cancelled
This commit is contained in:
parent
d3da56891b
commit
b8dab70de5
@ -172,6 +172,9 @@ jobs:
|
|||||||
runs-on: zero-ppanel-server
|
runs-on: zero-ppanel-server
|
||||||
container: # <-- 整个 build job 在 Node.js 容器中运行
|
container: # <-- 整个 build job 在 Node.js 容器中运行
|
||||||
image: node:20.15.1
|
image: node:20.15.1
|
||||||
|
volumes:
|
||||||
|
- /root/go/pkg/mod:/root/go/pkg/mod
|
||||||
|
- /root/.cache/go-build:/root/.cache/go-build
|
||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.has_changes == 'true' && contains(needs.prepare.outputs.changed_services, matrix.service.name)
|
if: needs.prepare.outputs.has_changes == 'true' && contains(needs.prepare.outputs.changed_services, matrix.service.name)
|
||||||
strategy:
|
strategy:
|
||||||
@ -206,16 +209,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: '1.24.0' # 确保使用 go.mod 中指定的精确版本
|
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
|
- name: 🔧 确保 Docker CLI 可用并初始化 Go Modules
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -15,6 +15,7 @@ logs/
|
|||||||
etc/*.local.yaml
|
etc/*.local.yaml
|
||||||
apps/*/etc/*.local.yaml
|
apps/*/etc/*.local.yaml
|
||||||
deploy/.env
|
deploy/.env
|
||||||
|
goctl_tpl/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@ -49,6 +49,9 @@ Security:
|
|||||||
Enable: true
|
Enable: true
|
||||||
SecuritySecret: "uB4G,XxL2{7b"
|
SecuritySecret: "uB4G,XxL2{7b"
|
||||||
|
|
||||||
|
Device:
|
||||||
|
Enable: true
|
||||||
|
|
||||||
Asynq:
|
Asynq:
|
||||||
Addr: 127.0.0.1:6379
|
Addr: 127.0.0.1:6379
|
||||||
|
|
||||||
|
|||||||
@ -23,4 +23,7 @@ type Config struct {
|
|||||||
Enable bool
|
Enable bool
|
||||||
SecuritySecret string
|
SecuritySecret string
|
||||||
}
|
}
|
||||||
|
Device struct {
|
||||||
|
Enable bool
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
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 设备登录
|
// 1. 调用 Core RPC 设备登录
|
||||||
rpcResp, err := l.svcCtx.CoreRpc.DeviceLogin(l.ctx, &core.DeviceLoginReq{
|
rpcResp, err := l.svcCtx.CoreRpc.DeviceLogin(l.ctx, &core.DeviceLoginReq{
|
||||||
Identifier: req.Identifier,
|
Identifier: req.Identifier,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/zero-ppanel/zero-ppanel/apps/api/internal/config"
|
"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 {
|
func (m *DecryptMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if !m.conf.Security.Enable {
|
||||||
next(w, r)
|
next(w, r)
|
||||||
return
|
return
|
||||||
@ -86,7 +89,12 @@ func (m *DecryptMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
httpx.Error(w, xerr.NewErrCode(xerr.DecryptFailed))
|
httpx.Error(w, xerr.NewErrCode(xerr.DecryptFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
fmt.Printf("[DEBUG] decrypted body: %s\n", string(plain))
|
||||||
r.Body = io.NopCloser(bytes.NewBuffer(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)
|
next(rw, r)
|
||||||
|
|||||||
148
test_device_login.mjs
Normal file
148
test_device_login.mjs
Normal file
@ -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);
|
||||||
Loading…
x
Reference in New Issue
Block a user