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,74 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { isValidToken } = load(
|
||||
path.join(__dirname, '../../back/shared/auth.ts'),
|
||||
);
|
||||
const { hashPassword, verifyPassword } = load(
|
||||
path.join(__dirname, '../../back/shared/password.ts'),
|
||||
);
|
||||
const secret = 'security-test-secret';
|
||||
const token = (payload = {}, options = {}) =>
|
||||
jwt.sign(payload, secret, {
|
||||
algorithm: 'HS384',
|
||||
expiresIn: '1h',
|
||||
...options,
|
||||
});
|
||||
const info = (value) => ({ token: value, tokens: {} });
|
||||
|
||||
test('valid legacy and platform sessions retain compatibility', () => {
|
||||
const value = token();
|
||||
for (const auth of [
|
||||
info(value),
|
||||
{ tokens: { desktop: value } },
|
||||
{ tokens: { desktop: [{ value }] } },
|
||||
]) {
|
||||
assert.equal(isValidToken(auth, value, 'desktop', secret), true);
|
||||
}
|
||||
assert.equal(
|
||||
isValidToken({ tokens: { mobile: value } }, value, 'desktop', secret),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('session membership never bypasses JWT signature, expiry, algorithm or nbf', () => {
|
||||
const values = [
|
||||
token({}, { expiresIn: -1 }),
|
||||
token({}, { algorithm: 'HS256' }),
|
||||
token({ nbf: Math.floor(Date.now() / 1000) + 60 }),
|
||||
jwt.sign({}, 'wrong-secret', { algorithm: 'HS384', expiresIn: '1h' }),
|
||||
jwt.sign({}, secret, { algorithm: 'HS384' }),
|
||||
'not-a-token',
|
||||
];
|
||||
for (const value of values)
|
||||
assert.equal(isValidToken(info(value), value, 'desktop', secret), false);
|
||||
assert.equal(isValidToken({ tokens: {} }, token(), 'desktop', secret), false);
|
||||
assert.equal(isValidToken(null, token(), 'desktop', secret), false);
|
||||
assert.equal(isValidToken(info(''), '', 'desktop', secret), false);
|
||||
});
|
||||
|
||||
test('platform token metadata can shorten but cannot extend JWT expiration', () => {
|
||||
const value = token();
|
||||
assert.equal(
|
||||
isValidToken(
|
||||
{ tokens: { desktop: [{ value, expiration: 1 }] } },
|
||||
value,
|
||||
'desktop',
|
||||
secret,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('passwords are salted and legacy plaintext can migrate without changing the password', async () => {
|
||||
const first = await hashPassword('owner-password');
|
||||
const second = await hashPassword('owner-password');
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(await verifyPassword('owner-password', first), true);
|
||||
assert.equal(await verifyPassword('wrong-password', first), false);
|
||||
assert.equal(await verifyPassword(first, first), false);
|
||||
assert.equal(await verifyPassword('old-password', 'old-password'), true);
|
||||
assert.equal(await verifyPassword('wrong', 'old-password'), false);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { resolveFileAccess } = load(
|
||||
path.join(__dirname, '../../back/shared/fileAccess.ts'),
|
||||
);
|
||||
|
||||
test('file access enforces directory boundaries, blacklist descendants and symlinks', (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-file-security-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const root = path.join(tmp, 'config');
|
||||
fs.mkdirSync(path.join(root, 'grpc'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmp, 'config-other'));
|
||||
fs.writeFileSync(path.join(root, 'normal.txt'), 'normal');
|
||||
fs.writeFileSync(path.join(root, 'grpc', 'client.key'), 'test-only');
|
||||
fs.symlinkSync(path.join(tmp, 'config-other'), path.join(root, 'outside'));
|
||||
fs.symlinkSync(path.join(root, 'grpc'), path.join(root, 'alias'));
|
||||
fs.symlinkSync(path.join(root, 'normal.txt'), path.join(root, 'safe-link'));
|
||||
fs.symlinkSync(path.join(tmp, 'missing'), path.join(root, 'dangling'));
|
||||
for (const input of [
|
||||
'../config-other/secret',
|
||||
'/etc/passwd',
|
||||
'grpc/client.key',
|
||||
'alias/client.key',
|
||||
'outside/new.txt',
|
||||
'dangling',
|
||||
'auth.json',
|
||||
'nested/auth.json',
|
||||
'',
|
||||
]) {
|
||||
assert.equal(
|
||||
resolveFileAccess(root, [input], ['grpc', 'auth.json']),
|
||||
'',
|
||||
input,
|
||||
);
|
||||
}
|
||||
for (const input of ['normal.txt', 'safe-link', 'new-dir/new.txt']) {
|
||||
assert.equal(
|
||||
resolveFileAccess(root, [input], ['grpc']),
|
||||
path.join(root, input),
|
||||
input,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('HTTP authentication protects init, scopes, expired sessions and config secrets', async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-http-security-'));
|
||||
for (const dir of ['config/grpc', 'scripts', 'upload', 'tmp'])
|
||||
fs.mkdirSync(path.join(tmp, dir), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, 'config/grpc/client.key'), 'SENTINEL');
|
||||
fs.writeFileSync(path.join(tmp, 'config/normal.txt'), 'normal');
|
||||
const secret = 'http-security-test';
|
||||
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
|
||||
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
|
||||
const auth = {
|
||||
username: 'owner',
|
||||
password: 'configured',
|
||||
token: valid,
|
||||
tokens: { desktop: [{ value: expired }] },
|
||||
};
|
||||
const configSource = fs.readFileSync(
|
||||
path.join(__dirname, '../../back/config/index.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const whitelistSource = configSource
|
||||
.slice(configSource.indexOf('apiWhiteList:'))
|
||||
.match(/apiWhiteList:\s*\[([\s\S]*?)\]/)[1];
|
||||
const config = {
|
||||
api: { prefix: '/api' },
|
||||
apiWhiteList: [...whitelistSource.matchAll(/['"]([^'"]+)['"]/g)].map(
|
||||
(x) => x[1],
|
||||
),
|
||||
jwt: { secret },
|
||||
rootPath: tmp,
|
||||
configPath: path.join(tmp, 'config/'),
|
||||
scriptPath: path.join(tmp, 'scripts/'),
|
||||
uploadPath: path.join(tmp, 'upload'),
|
||||
tmpPath: path.join(tmp, 'tmp'),
|
||||
blackFileList: ['auth.json', 'grpc'],
|
||||
baseUrl: '/panel',
|
||||
};
|
||||
const apps = [
|
||||
{
|
||||
scopes: ['configs'],
|
||||
tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }],
|
||||
},
|
||||
{
|
||||
scopes: ['envs'],
|
||||
tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }],
|
||||
},
|
||||
];
|
||||
let initialized = 0;
|
||||
const User = class {};
|
||||
const user = {
|
||||
initializeUser: async () => {
|
||||
initialized++;
|
||||
return { code: 200 };
|
||||
},
|
||||
getAuthInfo: async () => auth,
|
||||
};
|
||||
const mocks = {
|
||||
'../config': config,
|
||||
'../config/util': {
|
||||
getToken: (r) => (r.headers.authorization || '').replace(/^Bearer /, ''),
|
||||
getPlatform: () => 'desktop',
|
||||
getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'),
|
||||
},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'../shared/store': {
|
||||
shareStore: { getAuthInfo: async () => auth, getApps: async () => apps },
|
||||
},
|
||||
'../config/serverEnv': { serveEnv: (_req, res) => res.end() },
|
||||
'../services/user': User,
|
||||
'../data/open': {},
|
||||
'../data/system': {},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (p, content) => fs.promises.writeFile(p, content),
|
||||
},
|
||||
};
|
||||
const Config = load(path.join(__dirname, '../../back/services/config.ts'), {
|
||||
...mocks,
|
||||
typedi: { Service: () => (x) => x },
|
||||
}).default;
|
||||
const configService = new Config();
|
||||
mocks['../services/config'] = Config;
|
||||
mocks.typedi = {
|
||||
Container: { get: (x) => (x === User ? user : configService) },
|
||||
};
|
||||
mocks['../api'] = () => {
|
||||
const router = express.Router();
|
||||
load(path.join(__dirname, '../../back/api/user.ts'), mocks).default(router);
|
||||
load(path.join(__dirname, '../../back/api/config.ts'), mocks).default(
|
||||
router,
|
||||
);
|
||||
router.get('/envs', (_req, res) => res.json({ code: 200 }));
|
||||
return router;
|
||||
};
|
||||
const app = express();
|
||||
load(path.join(__dirname, '../../back/loaders/express.ts'), mocks).default({
|
||||
app,
|
||||
});
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
t.after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.on('listening', resolve);
|
||||
server.on('error', reject);
|
||||
});
|
||||
const request = async (url, token, method = 'GET', body) => {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}${url}`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
},
|
||||
);
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
for (const url of [
|
||||
'/api/user/init',
|
||||
'/open/user/init',
|
||||
'/panel/api/user/init',
|
||||
'/panel/open/user/init',
|
||||
]) {
|
||||
const response = await request(url, undefined, 'PUT', {
|
||||
username: 'attacker',
|
||||
password: 'changed',
|
||||
});
|
||||
assert.equal(response.body.code, 450, url);
|
||||
}
|
||||
assert.equal(initialized, 0);
|
||||
assert.equal(
|
||||
(
|
||||
await request('/api/user/login', undefined, 'POST', {
|
||||
username: 'x',
|
||||
password: 'x'.repeat(20000),
|
||||
})
|
||||
).status,
|
||||
413,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, 'upload/legacy.html'),
|
||||
'<script>window.test=1</script>',
|
||||
);
|
||||
const legacyUpload = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}/api/static/legacy.html`,
|
||||
);
|
||||
assert.equal(legacyUpload.headers.get('x-content-type-options'), 'nosniff');
|
||||
assert.equal(
|
||||
legacyUpload.headers.get('content-security-policy'),
|
||||
"sandbox; default-src 'none'",
|
||||
);
|
||||
for (const prefix of ['/api', '/open', '/panel/api', '/panel/open']) {
|
||||
assert.equal(
|
||||
(await request(`${prefix}/envs`, expired)).status,
|
||||
401,
|
||||
prefix,
|
||||
);
|
||||
assert.equal(
|
||||
(await request(`${prefix}/envs`, valid)).body.code,
|
||||
200,
|
||||
prefix,
|
||||
);
|
||||
assert.equal((await request(`${prefix}/envs`)).status, 401, prefix);
|
||||
}
|
||||
assert.equal((await request('/open/envs', 'env-app')).body.code, 200);
|
||||
assert.equal(
|
||||
(await request('/open/configs/detail?path=normal.txt', 'env-app')).status,
|
||||
401,
|
||||
);
|
||||
assert.equal(
|
||||
(await request('/open/configs/detail?path=normal.txt', 'config-app')).body
|
||||
.data,
|
||||
'normal',
|
||||
);
|
||||
assert.equal(
|
||||
(await request('/open/configs/detail?path=grpc/client.key', 'config-app'))
|
||||
.body.code,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request('/open/configs/save', 'config-app', 'POST', {
|
||||
name: 'grpc/client.key',
|
||||
content: 'changed',
|
||||
})
|
||||
).body.code,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(tmp, 'config/grpc/client.key'), 'utf8'),
|
||||
'SENTINEL',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request('/Api/user/init', undefined, 'PUT', {
|
||||
username: 'x',
|
||||
password: 'y',
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const logger = { debug() {}, warn() {}, error() {} };
|
||||
|
||||
for (const host of ['127.0.0.1', '::1', '192.0.2.1', '::']) {
|
||||
test(`HTTP binding ${host} never broadens an explicit private address`, async () => {
|
||||
const Http = load(path.join(__dirname, '../../back/services/http.ts'), {
|
||||
'../config': { bindHost: host },
|
||||
'../loaders/logger': logger,
|
||||
'./metrics': { metricsService: { record() {} } },
|
||||
typedi: { Service: () => (x) => x },
|
||||
}).HttpServerService;
|
||||
const instance = new Http();
|
||||
const attempted = [];
|
||||
instance.tryListen = async (_app, _port, address) => {
|
||||
attempted.push(address);
|
||||
throw new Error('unavailable');
|
||||
};
|
||||
await assert.rejects(instance.initialize({}, 5700));
|
||||
assert.deepEqual(attempted, host === '::' ? ['::', '0.0.0.0'] : [host]);
|
||||
});
|
||||
test(`gRPC binding ${host} never broadens an explicit private address`, async () => {
|
||||
const attempted = [];
|
||||
const Grpc = load(path.join(__dirname, '../../back/services/grpc.ts'), {
|
||||
'../config': { bindHostGrpc: host, grpcPort: 5500 },
|
||||
'../loaders/logger': logger,
|
||||
'./metrics': { metricsService: { record() {} } },
|
||||
typedi: { Service: () => (x) => x },
|
||||
'@grpc/grpc-js': {
|
||||
Server: class {
|
||||
addService() {}
|
||||
bindAsync(address, _credentials, cb) {
|
||||
attempted.push(address);
|
||||
cb(new Error('unavailable'));
|
||||
}
|
||||
},
|
||||
ServerCredentials: {
|
||||
createSsl(_ca, _certs, requireClientCert) {
|
||||
assert.equal(requireClientCert, true);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
'../protos/cron': { CronService: {} },
|
||||
'../protos/health': { HealthService: {} },
|
||||
'../protos/api': { ApiService: {} },
|
||||
'../schedule/addCron': {},
|
||||
'../schedule/delCron': {},
|
||||
'../schedule/health': {},
|
||||
'../schedule/api': {},
|
||||
'../config/grpcCerts': {
|
||||
initGrpcCerts: async () => ({
|
||||
caCert: 'test',
|
||||
serverCert: 'test',
|
||||
serverKey: 'test',
|
||||
}),
|
||||
},
|
||||
}).GrpcServerService;
|
||||
await assert.rejects(new Grpc().initialize());
|
||||
assert.deepEqual(
|
||||
attempted,
|
||||
host === '::' ? ['[::]:5500', '0.0.0.0:5500'] : [`${host}:5500`],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('WebSocket connections reject expired tokens and close when sessions are revoked', async () => {
|
||||
const secret = 'sock-test';
|
||||
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
|
||||
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
|
||||
let auth = { token: valid, tokens: { desktop: [{ value: expired }] } };
|
||||
let onConnection;
|
||||
const clients = new Set();
|
||||
const Sock = class {};
|
||||
const loader = load(path.join(__dirname, '../../back/loaders/sock.ts'), {
|
||||
sockjs: {
|
||||
createServer: () => ({
|
||||
on: (_event, fn) => {
|
||||
onConnection = fn;
|
||||
},
|
||||
installHandlers() {},
|
||||
}),
|
||||
},
|
||||
typedi: {
|
||||
Container: {
|
||||
get: () => ({
|
||||
addClient: (c) => clients.add(c),
|
||||
removeClient: (c) => clients.delete(c),
|
||||
}),
|
||||
},
|
||||
},
|
||||
'../services/sock': Sock,
|
||||
'../config': { jwt: { secret }, baseUrl: '' },
|
||||
'../config/util': { getPlatform: () => 'desktop' },
|
||||
'../shared/store': { shareStore: { getAuthInfo: async () => auth } },
|
||||
}).default;
|
||||
await loader({ server: {} });
|
||||
const connection = (token) => {
|
||||
const c = new EventEmitter();
|
||||
Object.assign(c, {
|
||||
headers: {},
|
||||
pathname: '/api/ws/a/b/websocket',
|
||||
url: `/api/ws/a/b/websocket?token=${token}`,
|
||||
close() {
|
||||
this.closed = true;
|
||||
this.emit('close');
|
||||
},
|
||||
write() {},
|
||||
});
|
||||
return c;
|
||||
};
|
||||
const rejected = connection(expired);
|
||||
await onConnection(rejected);
|
||||
assert.equal(rejected.closed, true);
|
||||
assert.equal(clients.size, 0);
|
||||
const accepted = connection(valid);
|
||||
await onConnection(accepted);
|
||||
assert.equal(clients.size, 1);
|
||||
auth = { token: '', tokens: {} };
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
accepted.close();
|
||||
reject(new Error('revoked socket remained open'));
|
||||
}, 3000);
|
||||
accepted.once('close', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
assert.equal(clients.size, 0);
|
||||
});
|
||||
@@ -46,7 +46,7 @@ test('initialization returns the username and password validation result', async
|
||||
paths: [],
|
||||
};
|
||||
Container.get = () => ({
|
||||
updateUsernameAndPassword: async () => ({
|
||||
initializeUser: async () => ({
|
||||
code: 400,
|
||||
message: 'password rejected',
|
||||
}),
|
||||
|
||||
@@ -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