mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-07 19:19:53 +08:00
Use MikroORM's official Node SQLite dialect with bounded database isolation, preserve legacy schema and data for rollback, and require Node 22.17 or newer. Update Debian runtimes and retain ARMv6 musl compatibility without a SQLite addon. Complete short archive writes, stop orphaned SQL workers after owner death, preserve buffered startup requests, and prestart the archived backend on every platform. Cover real package installation, migration, failure recovery, and legacy database compatibility. Keep authentication storage failures distinct from expired sessions with a pinned Keyv runtime, propagated cache errors, and retryable HTTP 503 responses. Preserve genuine 401 failures and verify legacy cache bytes, initialization safety, and session recovery.
346 lines
10 KiB
JavaScript
346 lines
10 KiB
JavaScript
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 (where, read = {}) => {
|
|
return { id: 1 };
|
|
},
|
|
nativeUpdate: async (where, data) => {
|
|
return 1;
|
|
},
|
|
insert: async (x) => {
|
|
return x;
|
|
},
|
|
find: async (where, read = {}) => {
|
|
return [];
|
|
},
|
|
nativeDelete: async (where, read = {}) => {},
|
|
};
|
|
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: (request) => request.socket.remoteAddress,
|
|
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, '');
|
|
});
|
|
test('one source cannot lock out another or revive a persisted global lock', async () => {
|
|
const f = fixture({ ...initialized(), retries: 40, lastlogon: Date.now() });
|
|
const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } };
|
|
const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } };
|
|
for (let i = 0; i < 3; i++) {
|
|
assert.equal(
|
|
(await f.user.login({ username: 'owner', password: 'wrong' }, attacker))
|
|
.code,
|
|
400,
|
|
);
|
|
}
|
|
assert.equal(
|
|
(
|
|
await f.user.login(
|
|
{ username: 'owner', password: 'old-password' },
|
|
attacker,
|
|
)
|
|
).code,
|
|
410,
|
|
);
|
|
assert.equal(
|
|
(await f.user.login({ username: 'owner', password: 'old-password' }, owner))
|
|
.code,
|
|
200,
|
|
);
|
|
// Another client's successful login does not reset the attacker's own budget.
|
|
assert.equal(
|
|
(await f.user.login({ username: 'owner', password: 'wrong' }, attacker))
|
|
.code,
|
|
410,
|
|
);
|
|
await f.user.resetAuthInfo({ retries: 0 });
|
|
assert.equal(
|
|
(
|
|
await f.user.login(
|
|
{ username: 'owner', password: 'old-password' },
|
|
attacker,
|
|
)
|
|
).code,
|
|
200,
|
|
);
|
|
});
|
|
test('TOTP guesses from another source do not lock the owner out', async () => {
|
|
const secret = authenticator.generateSecret();
|
|
const f = fixture({
|
|
...initialized(),
|
|
twoFactorActivated: true,
|
|
twoFactorSecret: secret,
|
|
});
|
|
const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } };
|
|
const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } };
|
|
await f.user.login({ username: 'owner', password: 'old-password' }, owner);
|
|
const good = authenticator.generate(secret);
|
|
const bad = good === '000000' ? '111111' : '000000';
|
|
for (let i = 0; i < 3; i++) {
|
|
assert.equal(
|
|
(
|
|
await f.user.twoFactorLogin(
|
|
{ username: 'owner', password: 'old-password', code: bad },
|
|
attacker,
|
|
)
|
|
).code,
|
|
430,
|
|
);
|
|
}
|
|
assert.equal(
|
|
(
|
|
await f.user.twoFactorLogin(
|
|
{ username: 'owner', password: 'old-password', code: bad },
|
|
attacker,
|
|
)
|
|
).code,
|
|
410,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await f.user.twoFactorLogin(
|
|
{ username: 'owner', password: 'old-password', code: good },
|
|
owner,
|
|
)
|
|
).code,
|
|
200,
|
|
);
|
|
});
|
|
test('source backoff is capped and expires after inactivity', () => {
|
|
const { LoginThrottle } = load(path.resolve('back/shared/loginThrottle.ts'));
|
|
const throttle = new LoginThrottle();
|
|
let now = 1;
|
|
for (let i = 0; i < 20; i++) {
|
|
throttle.fail('attacker', now);
|
|
const wait = throttle.retryAfter('attacker', now);
|
|
assert.ok(wait <= 300);
|
|
now += (wait + 1) * 1000;
|
|
}
|
|
assert.equal(throttle.retryAfter('owner', now), 0);
|
|
assert.equal(throttle.retryAfter('attacker', now + 15 * 60 * 1000), 0);
|
|
assert.equal(throttle.fail('attacker', now + 15 * 60 * 1000), 1);
|
|
});
|