mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-15 19:57:07 +08:00
fix: harden authentication and file access security
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { authenticator } = require('@otplib/preset-default');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { verifyPassword, isPasswordHash } = load(
|
||||
path.join(__dirname, '../../back/shared/password.ts'),
|
||||
);
|
||||
const req = {
|
||||
platform: 'desktop',
|
||||
headers: {},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
};
|
||||
|
||||
function fixture(initial) {
|
||||
let auth = structuredClone(initial);
|
||||
let closed = 0;
|
||||
const model = {
|
||||
findOne: async () => ({ id: 1 }),
|
||||
update: async () => [1, [{}]],
|
||||
create: async (x) => x,
|
||||
findAll: async () => [],
|
||||
destroy: async () => {},
|
||||
};
|
||||
const mocks = {
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
'../config': { jwt: { secret: 'test-secret' }, maxTokensPerPlatform: 10 },
|
||||
'../data/system': {
|
||||
AuthDataType: { authConfig: 'authConfig', loginLog: 'loginLog' },
|
||||
LoginStatus: { fail: 0, success: 1 },
|
||||
SystemModel: model,
|
||||
},
|
||||
'../config/util': {
|
||||
createRandomString: () =>
|
||||
require('node:crypto').randomBytes(32).toString('hex'),
|
||||
},
|
||||
'./notify': class {},
|
||||
'./schedule': class {},
|
||||
'./sock': class {},
|
||||
'../shared/store': {
|
||||
shareStore: {
|
||||
getAuthInfo: async () => auth,
|
||||
updateAuthInfo: async (x) => {
|
||||
auth = x;
|
||||
},
|
||||
},
|
||||
},
|
||||
'../shared/i18n': { t: (x) => x, tf: (x, y) => x.replace('%s', y) },
|
||||
'../shared/clientIp': {
|
||||
getClientIp: () => '127.0.0.1',
|
||||
normalizeClientIp: (x) => x,
|
||||
},
|
||||
ip2region: class {
|
||||
search() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
const User = load(
|
||||
path.join(__dirname, '../../back/services/user.ts'),
|
||||
mocks,
|
||||
).default;
|
||||
const user = new User(
|
||||
{ warn() {}, info() {} },
|
||||
{},
|
||||
{ getClients: () => [{ close: () => closed++ }] },
|
||||
);
|
||||
user.notificationService = { notify() {} };
|
||||
return {
|
||||
user,
|
||||
get auth() {
|
||||
return auth;
|
||||
},
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
};
|
||||
}
|
||||
const initialized = () => ({
|
||||
username: 'owner',
|
||||
password: 'old-password',
|
||||
token: 'stolen-token',
|
||||
tokens: { desktop: [{ value: 'stolen-token' }] },
|
||||
retries: 0,
|
||||
lastlogon: 0,
|
||||
});
|
||||
|
||||
test('initialization checks state inside a serialized mutation', async () => {
|
||||
const f = fixture({ username: 'admin', password: 'admin' });
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'admin', password: 'admin' }, req)).code,
|
||||
450,
|
||||
);
|
||||
assert.deepEqual(f.auth, { username: 'admin', password: 'admin' });
|
||||
const results = await Promise.all([
|
||||
f.user.initializeUser({ username: 'owner', password: 'first-password' }),
|
||||
f.user.initializeUser({
|
||||
username: 'attacker',
|
||||
password: 'second-password',
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
results.map((x) => x.code),
|
||||
[200, 450],
|
||||
);
|
||||
assert.equal(f.auth.username, 'owner');
|
||||
assert.equal(await verifyPassword('first-password', f.auth.password), true);
|
||||
assert.equal(
|
||||
(
|
||||
await f.user.initializeUser({
|
||||
username: 'attacker',
|
||||
password: 'third-password',
|
||||
})
|
||||
).code,
|
||||
450,
|
||||
);
|
||||
});
|
||||
|
||||
test('password change revokes sessions and closes connected clients', async () => {
|
||||
const f = fixture(initialized());
|
||||
await f.user.updateUsernameAndPassword({
|
||||
username: 'owner',
|
||||
password: 'new-password',
|
||||
});
|
||||
assert.equal(f.auth.token, '');
|
||||
assert.deepEqual(f.auth.tokens, {});
|
||||
assert.equal(f.closed, 1);
|
||||
assert.equal(await verifyPassword('new-password', f.auth.password), true);
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'owner', password: 'old-password' }, req))
|
||||
.code,
|
||||
400,
|
||||
);
|
||||
const login = await f.user.login(
|
||||
{ username: 'owner', password: 'new-password' },
|
||||
req,
|
||||
);
|
||||
assert.equal(login.code, 200);
|
||||
jwt.verify(login.data.token, 'test-secret', { algorithms: ['HS384'] });
|
||||
});
|
||||
|
||||
test('a concurrent old-password login cannot restore a session after reset', async () => {
|
||||
const f = fixture(initialized());
|
||||
const results = await Promise.all([
|
||||
f.user.login({ username: 'owner', password: 'old-password' }, req),
|
||||
f.user.resetAuthInfo({ password: 'new-password' }),
|
||||
]);
|
||||
assert.equal(results[0].code, 200);
|
||||
assert.equal(f.auth.token, '');
|
||||
assert.deepEqual(f.auth.tokens, {});
|
||||
assert.equal(await verifyPassword('new-password', f.auth.password), true);
|
||||
});
|
||||
|
||||
test('legacy plaintext migrates after a successful login', async () => {
|
||||
const f = fixture(initialized());
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'owner', password: 'old-password' }, req))
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
assert.equal(isPasswordHash(f.auth.password), true);
|
||||
assert.equal(await verifyPassword('old-password', f.auth.password), true);
|
||||
});
|
||||
|
||||
test('TOTP failures are counted serially and further attempts are throttled', async () => {
|
||||
const secret = authenticator.generateSecret();
|
||||
const f = fixture({
|
||||
...initialized(),
|
||||
twoFactorActivated: true,
|
||||
twoFactorSecret: secret,
|
||||
});
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'owner', password: 'old-password' }, req))
|
||||
.code,
|
||||
420,
|
||||
);
|
||||
const badCode =
|
||||
authenticator.generate(secret) === '000000' ? '111111' : '000000';
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 120 }, () =>
|
||||
f.user.twoFactorLogin(
|
||||
{ username: 'owner', password: 'old-password', code: badCode },
|
||||
req,
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.equal(f.auth.retries, 3);
|
||||
assert.equal(results.filter((x) => x.code === 430).length, 3);
|
||||
assert.equal(results.filter((x) => x.code === 410).length, 117);
|
||||
});
|
||||
|
||||
test('TOTP challenge expires and valid codes cannot be reused in the same step', async () => {
|
||||
const secret = authenticator.generateSecret();
|
||||
const f = fixture({
|
||||
...initialized(),
|
||||
twoFactorActivated: true,
|
||||
twoFactorSecret: secret,
|
||||
});
|
||||
await f.user.login({ username: 'owner', password: 'old-password' }, req);
|
||||
f.auth.twoFactorExpiresAt = Date.now() - 1;
|
||||
const payload = {
|
||||
username: 'owner',
|
||||
password: 'old-password',
|
||||
code: authenticator.generate(secret),
|
||||
};
|
||||
assert.equal((await f.user.twoFactorLogin(payload, req)).code, 450);
|
||||
await f.user.login({ username: 'owner', password: 'old-password' }, req);
|
||||
assert.equal((await f.user.twoFactorLogin(payload, req)).code, 200);
|
||||
await f.user.login({ username: 'owner', password: 'old-password' }, req);
|
||||
assert.equal((await f.user.twoFactorLogin(payload, req)).code, 430);
|
||||
});
|
||||
|
||||
test('active two-factor secret cannot be silently replaced and disabling revokes sessions', async () => {
|
||||
const f = fixture({
|
||||
...initialized(),
|
||||
twoFactorActivated: true,
|
||||
twoFactorSecret: authenticator.generateSecret(),
|
||||
});
|
||||
await assert.rejects(f.user.initTwoFactor());
|
||||
await f.user.deactivateTwoFactor();
|
||||
assert.equal(f.auth.token, '');
|
||||
assert.deepEqual(f.auth.tokens, {});
|
||||
assert.equal(f.auth.twoFactorSecret, '');
|
||||
});
|
||||
|
||||
test('default credentials with historical metadata still require initialization', async () => {
|
||||
const f = fixture({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
retries: 1,
|
||||
token: 'old-default-session',
|
||||
});
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'admin', password: 'admin' }, req)).code,
|
||||
450,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await f.user.initializeUser({
|
||||
username: 'owner',
|
||||
password: 'new-password',
|
||||
})
|
||||
).code,
|
||||
200,
|
||||
);
|
||||
assert.equal(f.auth.token, '');
|
||||
});
|
||||
Reference in New Issue
Block a user