完善了安全功能

This commit is contained in:
lik
2026-06-12 15:24:20 +08:00
parent fba44ca015
commit ddcf200de2
12 changed files with 904 additions and 207 deletions

265
test/api.test.js Normal file
View File

@@ -0,0 +1,265 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import supertest from 'supertest';
import { APP } from '../app.js';
import { DBModel } from '../models/index.js';
describe('用户接口集成测试', () => {
let app, agent;
const testMobile = '13900000001';
const testPasswd = 'test123456';
let token = '';
let userId = '';
before(async () => {
app = new APP();
agent = supertest(app.app.callback());
// 等待数据库连接
await new Promise(resolve => setTimeout(resolve, 2000));
});
after(async () => {
// 清理测试数据
if (userId) {
await DBModel.User.delUser(userId);
}
app.stop();
});
describe('POST /user/register', () => {
it('应成功注册新用户', async () => {
const res = await agent
.post('/user/register')
.send({
userInfo: {
profile: { mobile: testMobile, name: '测试用户' },
security: { passwd: testPasswd },
},
});
assert.equal(res.body.code, 0, `注册失败: ${res.body.msg}`);
assert.ok(res.body.data.user);
assert.ok(res.body.data.user.security.token);
assert.equal(res.body.data.user.profile.mobile, testMobile);
// 密码字段不应返回
assert.equal(res.body.data.user.security.passwd, undefined);
assert.equal(res.body.data.user.security.passwdSalt, undefined);
token = res.body.data.user.security.token;
userId = res.body.data.user._id;
});
it('重复注册应返回 409', async () => {
const res = await agent
.post('/user/register')
.send({
userInfo: {
profile: { mobile: testMobile, name: '测试用户' },
security: { passwd: testPasswd },
},
});
assert.equal(res.body.code, 409);
assert.equal(res.body.msg, '手机号已注册');
});
it('缺少手机号应返回 400', async () => {
const res = await agent
.post('/user/register')
.send({
userInfo: {
profile: { name: '测试用户' },
security: { passwd: testPasswd },
},
});
assert.equal(res.body.code, 400);
});
it('缺少密码应返回 400', async () => {
const res = await agent
.post('/user/register')
.send({
userInfo: {
profile: { mobile: '13900000099' },
security: {},
},
});
assert.equal(res.body.code, 400);
});
it('密码少于6位应返回 400', async () => {
const res = await agent
.post('/user/register')
.send({
userInfo: {
profile: { mobile: '13900000098' },
security: { passwd: '123' },
},
});
assert.equal(res.body.code, 400);
});
});
describe('POST /user/signin', () => {
it('用正确密码登录应成功', async () => {
const res = await agent
.post('/user/signin')
.send({ mobile: testMobile, passwd: testPasswd });
assert.equal(res.body.code, 0, `登录失败: ${res.body.msg}`);
assert.ok(res.body.data.user.security.token);
// 更新 token
token = res.body.data.user.security.token;
// 应记录最后登录信息
assert.ok(res.body.data.user.security.lastLoginAt);
});
it('错误密码应返回 401', async () => {
const res = await agent
.post('/user/signin')
.send({ mobile: testMobile, passwd: 'wrongpassword' });
assert.equal(res.body.code, 401);
});
it('不存在的用户应返回 401', async () => {
const res = await agent
.post('/user/signin')
.send({ mobile: '13999999999', passwd: 'test123456' });
assert.equal(res.body.code, 401);
});
it('缺少手机号应返回 400', async () => {
const res = await agent
.post('/user/signin')
.send({ passwd: testPasswd });
assert.equal(res.body.code, 400);
});
});
describe('POST /user/userInfo', () => {
it('用 Authorization Bearer 获取用户信息', async () => {
const res = await agent
.post('/user/userInfo')
.set('Authorization', `Bearer ${token}`)
.send({});
assert.equal(res.body.code, 0, `获取用户信息失败: ${res.body.msg}`);
assert.equal(res.body.data.user.profile.mobile, testMobile);
assert.equal(res.body.data.user.security.passwd, undefined);
});
it('用 body token 获取用户信息(向后兼容)', async () => {
const res = await agent
.post('/user/userInfo')
.send({ token });
assert.equal(res.body.code, 0);
assert.equal(res.body.data.user.profile.mobile, testMobile);
});
it('无效 token 应返回 401', async () => {
const res = await agent
.post('/user/userInfo')
.set('Authorization', 'Bearer invalid_token')
.send({});
assert.equal(res.body.code, 401);
});
it('缺少 token 应返回 400', async () => {
const res = await agent
.post('/user/userInfo')
.send({});
assert.equal(res.body.code, 400);
});
});
describe('POST /user/update', () => {
it('更新用户信息应成功', async () => {
const res = await agent
.post('/user/update')
.set('Authorization', `Bearer ${token}`)
.send({
profile: { name: '更新后的名字' },
});
assert.equal(res.body.code, 0, `更新失败: ${res.body.msg}`);
assert.equal(res.body.data.user.profile.name, '更新后的名字');
});
it('无 token 更新应返回 400', async () => {
const res = await agent
.post('/user/update')
.send({ profile: { name: 'test' } });
assert.equal(res.body.code, 400);
});
});
describe('POST /user/signout', () => {
it('退出登录应成功', async () => {
const res = await agent
.post('/user/signout')
.set('Authorization', `Bearer ${token}`)
.send({});
assert.equal(res.body.code, 0);
assert.equal(res.body.msg, '退出登录成功');
});
it('退出后用旧 token 获取信息应返回 401', async () => {
const res = await agent
.post('/user/userInfo')
.set('Authorization', `Bearer ${token}`)
.send({});
assert.equal(res.body.code, 401);
});
});
describe('密码迁移MD5 → bcrypt', () => {
it('旧 MD5 用户登录应自动升级密码', async () => {
// 先注册一个新用户
const mobile = '13900000002';
const passwd = 'md5test123';
// 直接写入一个 MD5 密码的用户
const crypto = await import('crypto');
const salt = crypto.randomBytes(8).toString('hex');
const md5Hash = crypto.createHash('md5').update(passwd + salt).digest('hex');
const newUser = {
profile: { mobile, name: 'MD5测试用户' },
security: { passwd: md5Hash, passwdSalt: salt },
status: { account: 'normal' },
app: {},
social: { wechat: {} },
};
const user = await DBModel.User.setUser(newUser);
assert.ok(user);
// 用旧密码登录
const res = await agent
.post('/user/signin')
.send({ mobile, passwd });
assert.equal(res.body.code, 0, `MD5迁移登录失败: ${res.body.msg}`);
// 验证密码已升级为 bcrypt
const updatedUser = await DBModel.User.findOne({ 'profile.mobile': mobile });
assert.ok(updatedUser.security.passwd.startsWith('$2'), '密码应已升级为 bcrypt');
assert.equal(updatedUser.security.passwdSalt, undefined, 'salt 应已清除');
// 清理
await DBModel.User.delUser(updatedUser._id);
});
});
});

85
test/auth.test.js Normal file
View File

@@ -0,0 +1,85 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { extractToken, sanitizeUser } from '../middleware/auth.js';
// 模拟 ctx 对象
function mockCtx(overrides = {}) {
return {
header: {},
request: { body: {}, query: {} },
ip: '127.0.0.1',
...overrides,
};
}
describe('extractToken', () => {
it('优先从 Authorization Bearer header 提取', () => {
const ctx = mockCtx({
header: { authorization: 'Bearer abc123' },
request: { body: { token: 'body_token' } },
});
assert.equal(extractToken(ctx), 'abc123');
});
it('无 Bearer header 时从 body 提取', () => {
const ctx = mockCtx({
request: { body: { token: 'body_token' } },
});
assert.equal(extractToken(ctx), 'body_token');
});
it('无 Bearer header 时从 query 提取', () => {
const ctx = mockCtx({
request: { query: { token: 'query_token' } },
});
assert.equal(extractToken(ctx), 'query_token');
});
it('无 Bearer header 时从 header token 字段提取', () => {
const ctx = mockCtx({
header: { token: 'header_token' },
});
assert.equal(extractToken(ctx), 'header_token');
});
it('无任何 token 时返回 undefined', () => {
const ctx = mockCtx();
assert.equal(extractToken(ctx), undefined);
});
});
describe('sanitizeUser', () => {
it('应删除密码和重置令牌相关字段', () => {
const user = {
toObject: () => ({
profile: { mobile: '13800138000' },
security: {
passwd: 'hashed',
passwdSalt: 'salt',
token: 'valid_token',
passwordResetToken: 'reset_token',
passwordResetExpiry: new Date(),
},
}),
};
const safe = sanitizeUser(user);
assert.equal(safe.security.passwd, undefined);
assert.equal(safe.security.passwdSalt, undefined);
assert.equal(safe.security.passwordResetToken, undefined);
assert.equal(safe.security.passwordResetExpiry, undefined);
assert.equal(safe.security.token, 'valid_token');
assert.equal(safe.profile.mobile, '13800138000');
});
it('处理普通对象(无 toObject 方法)', () => {
const user = {
profile: { mobile: '13800138000' },
security: { passwd: 'x', passwdSalt: 'y' },
};
const safe = sanitizeUser(user);
assert.equal(safe.security.passwd, undefined);
assert.equal(safe.security.passwdSalt, undefined);
});
});

51
test/crypto.test.js Normal file
View File

@@ -0,0 +1,51 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { hashPassword, verifyPassword } from '../utils/crypto.js';
describe('crypto 工具', () => {
it('bcrypt 加密后验证应通过', async () => {
const passwd = 'test123456';
const hash = await hashPassword(passwd);
assert.ok(hash.startsWith('$2'), 'bcrypt hash 应以 $2 开头');
const { valid, needsUpgrade } = await verifyPassword(passwd, hash, '');
assert.equal(valid, true);
assert.equal(needsUpgrade, false);
});
it('错误密码验证应失败', async () => {
const hash = await hashPassword('correct');
const { valid } = await verifyPassword('wrong', hash, '');
assert.equal(valid, false);
});
it('兼容旧 MD5 密码验证', async () => {
const passwd = 'mypass';
const salt = 'abc123';
const crypto = await import('crypto');
const md5Hash = crypto.createHash('md5').update(passwd + salt).digest('hex');
const { valid, needsUpgrade } = await verifyPassword(passwd, md5Hash, salt);
assert.equal(valid, true);
assert.equal(needsUpgrade, true, 'MD5 密码应标记为需要升级');
});
it('MD5 密码错误时应返回 false', async () => {
const salt = 'abc123';
const crypto = await import('crypto');
const md5Hash = crypto.createHash('md5').update('correct' + salt).digest('hex');
const { valid } = await verifyPassword('wrong', md5Hash, salt);
assert.equal(valid, false);
});
it('空 salt 时 MD5 也能验证', async () => {
const passwd = 'test';
const crypto = await import('crypto');
const md5Hash = crypto.createHash('md5').update(passwd + '').digest('hex');
const { valid, needsUpgrade } = await verifyPassword(passwd, md5Hash, '');
assert.equal(valid, true);
assert.equal(needsUpgrade, true);
});
});

49
test/ratelimit.test.js Normal file
View File

@@ -0,0 +1,49 @@
import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import supertest from 'supertest';
import { rateLimit } from '../middleware/ratelimit.js';
describe('rateLimit 中间件', () => {
let app, agent;
before(() => {
app = new Koa();
app.use(bodyParser());
app.use(rateLimit(3, 60_000));
app.use(async (ctx) => {
ctx.body = { ok: true };
});
agent = supertest(app.callback());
});
it('前3次请求应正常通过', async () => {
for (let i = 0; i < 3; i++) {
const res = await agent.post('/test').send({});
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
}
});
it('第4次请求应被限流', async () => {
const res = await agent.post('/test').send({});
assert.equal(res.status, 200); // HTTP 200业务码 429
assert.equal(res.body.code, 429);
assert.ok(res.body.msg.includes('请求过于频繁'));
assert.ok(res.headers['retry-after']);
});
it('限流响应头应包含 X-RateLimit 信息', async () => {
// 新建一个不受之前计数影响的中间件(不同限流阈值 + 不同路径)
const freshApp = new Koa();
freshApp.use(bodyParser());
freshApp.use(rateLimit(100, 60_000));
freshApp.use(async (ctx) => { ctx.body = { ok: true }; });
const freshAgent = supertest(freshApp.callback());
const res = await freshAgent.post('/headercheck').send({});
assert.equal(res.headers['x-ratelimit-limit'], '100');
assert.equal(res.headers['x-ratelimit-remaining'], '99');
});
});