This commit is contained in:
张成
2026-04-01 13:42:29 +08:00
parent 1d22fb28e2
commit 2d900ef2ac
9 changed files with 187 additions and 8 deletions

View File

@@ -19,6 +19,7 @@ module.exports = {
offset,
limit: page_size,
order: [["id", "DESC"]],
attributes: { exclude: ["secret_cipher"] },
});
ctx.success({ rows, count });
},
@@ -85,6 +86,7 @@ module.exports = {
where,
limit: 10000,
order: [["id", "DESC"]],
attributes: { exclude: ["secret_cipher"] },
});
ctx.success({ rows });
},

View File

@@ -3,6 +3,17 @@ const { normalize_for_write, build_search_where } = require("../utils/query_help
const baseModel = require("../../middleware/baseModel");
const tokenLogic = require("../service/biz_token_logic");
const audit = require("../utils/biz_audit");
const biz_token_secret_cipher = require("../utils/biz_token_secret_cipher");
function map_tokens_for_admin_detail(token_rows) {
return token_rows.map((t) => {
const o = t.get ? t.get({ plain: true }) : { ...t };
const cipher = o.secret_cipher;
delete o.secret_cipher;
o.plain_token = biz_token_secret_cipher.decrypt_plain_from_storage(cipher);
return o;
});
}
module.exports = {
"POST /biz_user/page": async (ctx) => {
@@ -134,13 +145,13 @@ module.exports = {
where: { user_id: id },
order: [["id", "DESC"]],
limit: 200,
attributes: ["id", "user_id", "plan_id", "token_name", "status", "expire_at", "last_used_at"],
});
const tokens_out = map_tokens_for_admin_detail(tokens);
ctx.success({
user,
subscriptions,
tokenCount,
tokens,
tokens: tokens_out,
});
},
"GET /biz_user/all": async (ctx) => {

View File

@@ -23,6 +23,10 @@ module.exports = (db) => {
allowNull: false,
unique: true,
},
secret_cipher: {
type: Sequelize.TEXT,
allowNull: true,
},
status: {
type: Sequelize.ENUM("active", "revoked", "expired"),
allowNull: false,

View File

@@ -2,6 +2,7 @@ const crypto = require("crypto");
const Sequelize = require("sequelize");
const op = Sequelize.Op;
const baseModel = require("../../middleware/baseModel");
const biz_token_secret_cipher = require("../utils/biz_token_secret_cipher");
const MAX_TOKENS_PER_USER = 5;
@@ -53,12 +54,14 @@ async function createToken(body) {
const plain = generatePlainToken();
const token_hash = hashPlainToken(plain);
const secret_cipher = biz_token_secret_cipher.encrypt_plain_for_storage(plain);
const row = await baseModel.biz_api_token.create({
user_id,
plan_id,
token_name: token_name || "default",
token_hash,
secret_cipher,
status: "active",
expire_at,
});
@@ -75,7 +78,7 @@ async function revokeToken(body) {
if (id == null) throw new Error("缺少 id");
const row = await baseModel.biz_api_token.findByPk(id);
if (!row) throw new Error("Token 不存在");
await row.update({ status: "revoked" });
await row.update({ status: "revoked", secret_cipher: null });
return row;
}
@@ -98,10 +101,12 @@ async function regenerateToken(body) {
const plain = generatePlainToken();
const token_hash = hashPlainToken(plain);
const secret_cipher = biz_token_secret_cipher.encrypt_plain_for_storage(plain);
await row.update({
token_hash,
plan_id,
secret_cipher,
});
await row.reload();
@@ -115,7 +120,7 @@ async function regenerateToken(body) {
async function revokeAllForUser(userId) {
if (userId == null) throw new Error("缺少 user_id");
const [n] = await baseModel.biz_api_token.update(
{ status: "revoked" },
{ status: "revoked", secret_cipher: null },
{ where: { user_id: userId, status: "active" } }
);
return n;

View File

@@ -0,0 +1,51 @@
const crypto = require("crypto");
const config = require("../../config/config");
/**
* 管理端查看 Token 明文用AES-256-GCM密钥来自环境变量或 config.biz_token_enc_key。
*/
function get_key_32() {
const raw = process.env.BIZ_TOKEN_ENC_KEY || config.biz_token_enc_key;
if (!raw) {
return crypto.createHash("sha256").update("dev-biz-token-enc-set-BIZ_TOKEN_ENC_KEY", "utf8").digest();
}
return crypto.createHash("sha256").update(String(raw), "utf8").digest();
}
function encrypt_plain_for_storage(plain) {
if (plain == null || plain === "") return null;
const key = get_key_32();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const enc = Buffer.concat([cipher.update(String(plain), "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
const payload = {
v: 1,
iv: iv.toString("base64"),
tag: tag.toString("base64"),
data: enc.toString("base64"),
};
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
}
function decrypt_plain_from_storage(stored) {
if (stored == null || stored === "") return null;
try {
const payload = JSON.parse(Buffer.from(stored, "base64").toString("utf8"));
if (payload.v !== 1) return null;
const key = get_key_32();
const iv = Buffer.from(payload.iv, "base64");
const tag = Buffer.from(payload.tag, "base64");
const data = Buffer.from(payload.data, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
} catch {
return null;
}
}
module.exports = {
encrypt_plain_for_storage,
decrypt_plain_from_storage,
};