完善了安全功能
This commit is contained in:
265
test/api.test.js
Normal file
265
test/api.test.js
Normal 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
85
test/auth.test.js
Normal 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
51
test/crypto.test.js
Normal 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
49
test/ratelimit.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user