mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:00:24 +08:00
417 lines
12 KiB
JavaScript
417 lines
12 KiB
JavaScript
require('ts-node/register/transpile-only');
|
|
|
|
const assert = require('node:assert/strict');
|
|
const { createHash } = require('node:crypto');
|
|
const { test } = require('node:test');
|
|
const jwt = require('jsonwebtoken');
|
|
const { Sequelize } = require('sequelize');
|
|
const {
|
|
defineSchemaMigrationModel,
|
|
} = require('../../back/data/schemaMigration');
|
|
const {
|
|
IDENTITY_AUTHENTICATION_BINDING_CURRENT_INDEX,
|
|
IDENTITY_AUTHENTICATION_BINDING_SUBJECT_INDEX,
|
|
IDENTITY_AUTHENTICATION_BINDING_TABLE,
|
|
IDENTITY_SUBJECT_STATUS_INDEX,
|
|
IDENTITY_SUBJECT_TABLE,
|
|
identityDirectoryMigration,
|
|
} = require('../../back/migrations/0019-identity-directory');
|
|
const { runMigrations } = require('../../back/migrations/runner');
|
|
const {
|
|
LegacyAuthInfoSessionSource,
|
|
LegacyPanelSessionUnavailableError,
|
|
MAX_LEGACY_PANEL_TOKENS_PER_PLATFORM,
|
|
} = require('../../back/runtime/adapters/authentication/legacyAuthInfoSessionSource');
|
|
const {
|
|
LegacySequelizeIdentityDirectoryRepository,
|
|
} = require('../../back/runtime/adapters/legacy-sequelize/identityDirectoryRepository');
|
|
const {
|
|
LegacyPanelAuthenticationRejectedError,
|
|
LegacyPanelAuthenticationService,
|
|
LegacyPanelAuthenticationUnavailableError,
|
|
} = require('../../back/runtime/application/legacyPanelAuthenticationService');
|
|
const {
|
|
IdentityDirectoryUnavailableError,
|
|
LEGACY_PANEL_IDENTITY_PROVIDER,
|
|
LEGACY_PANEL_PROVIDER_SUBJECT,
|
|
LEGACY_PRIMARY_USER_SUBJECT_ID,
|
|
} = require('../../back/runtime/domain/identityDirectory');
|
|
|
|
const SECRET = 'test-only-legacy-jwt-secret';
|
|
const ISSUED_AT_SECONDS = 100;
|
|
const EXPIRES_AT_SECONDS = 200;
|
|
const NOW_MS = 150_000;
|
|
|
|
function signToken(
|
|
payload = {
|
|
data: 'legacy-session-random-data',
|
|
iat: ISSUED_AT_SECONDS,
|
|
exp: EXPIRES_AT_SECONDS,
|
|
},
|
|
algorithm = 'HS384',
|
|
secret = SECRET,
|
|
) {
|
|
return jwt.sign(payload, secret, { algorithm });
|
|
}
|
|
|
|
async function setup(t) {
|
|
const database = new Sequelize({
|
|
dialect: 'sqlite',
|
|
storage: ':memory:',
|
|
logging: false,
|
|
});
|
|
t.after(() => database.close());
|
|
await runMigrations({
|
|
database,
|
|
migrationModel: defineSchemaMigrationModel(database),
|
|
migrations: [identityDirectoryMigration],
|
|
logger: { info() {} },
|
|
});
|
|
return {
|
|
database,
|
|
directory: new LegacySequelizeIdentityDirectoryRepository(database),
|
|
};
|
|
}
|
|
|
|
function sessionSource(snapshot) {
|
|
return new LegacyAuthInfoSessionSource(async () => snapshot);
|
|
}
|
|
|
|
function authentication(directory, snapshot, secret = SECRET) {
|
|
return new LegacyPanelAuthenticationService(
|
|
directory,
|
|
sessionSource(snapshot),
|
|
secret,
|
|
);
|
|
}
|
|
|
|
test('migration creates a stable singleton identity without copying legacy credentials', async (t) => {
|
|
const { database, directory } = await setup(t);
|
|
const subjects = await database
|
|
.getQueryInterface()
|
|
.select(null, IDENTITY_SUBJECT_TABLE);
|
|
const bindings = await database
|
|
.getQueryInterface()
|
|
.select(null, IDENTITY_AUTHENTICATION_BINDING_TABLE);
|
|
assert.deepEqual(subjects, [
|
|
{
|
|
id: LEGACY_PRIMARY_USER_SUBJECT_ID,
|
|
type: 'user',
|
|
status: 'active',
|
|
version: 1,
|
|
created_at_ms: 0,
|
|
updated_at_ms: 0,
|
|
},
|
|
]);
|
|
assert.deepEqual(bindings, [
|
|
{
|
|
provider: LEGACY_PANEL_IDENTITY_PROVIDER,
|
|
provider_subject: LEGACY_PANEL_PROVIDER_SUBJECT,
|
|
version: 1,
|
|
state: 'active',
|
|
subject_id: LEGACY_PRIMARY_USER_SUBJECT_ID,
|
|
created_at_ms: 0,
|
|
},
|
|
]);
|
|
const persisted = JSON.stringify({ subjects, bindings });
|
|
assert.equal(persisted.includes('username'), false);
|
|
assert.equal(persisted.includes('password'), false);
|
|
assert.equal(persisted.includes('token'), false);
|
|
assert.deepEqual(
|
|
await directory.resolveAuthenticationSubject(
|
|
LEGACY_PANEL_IDENTITY_PROVIDER,
|
|
LEGACY_PANEL_PROVIDER_SUBJECT,
|
|
),
|
|
{ type: 'user', id: LEGACY_PRIMARY_USER_SUBJECT_ID },
|
|
);
|
|
|
|
const subjectIndexes = new Set(
|
|
(await database.getQueryInterface().showIndex(IDENTITY_SUBJECT_TABLE)).map(
|
|
(index) => index.name,
|
|
),
|
|
);
|
|
const bindingIndexes = new Set(
|
|
(
|
|
await database
|
|
.getQueryInterface()
|
|
.showIndex(IDENTITY_AUTHENTICATION_BINDING_TABLE)
|
|
).map((index) => index.name),
|
|
);
|
|
assert.ok(subjectIndexes.has(IDENTITY_SUBJECT_STATUS_INDEX));
|
|
assert.ok(bindingIndexes.has(IDENTITY_AUTHENTICATION_BINDING_CURRENT_INDEX));
|
|
assert.ok(bindingIndexes.has(IDENTITY_AUTHENTICATION_BINDING_SUBJECT_INDEX));
|
|
});
|
|
|
|
test('authenticates a current HS384 legacy session as one stable single-factor user', async (t) => {
|
|
const { directory } = await setup(t);
|
|
const token = signToken();
|
|
const service = authentication(directory, {
|
|
token: '',
|
|
tokens: {
|
|
desktop: [
|
|
{
|
|
value: token,
|
|
timestamp: 123,
|
|
platform: 'desktop',
|
|
},
|
|
],
|
|
},
|
|
username: 'a-display-name-that-may-change',
|
|
twoFactorActivated: true,
|
|
});
|
|
assert.deepEqual(
|
|
await service.authenticate({ token, platform: 'desktop', nowMs: NOW_MS }),
|
|
{
|
|
subject: { type: 'user', id: LEGACY_PRIMARY_USER_SUBJECT_ID },
|
|
authenticationId: `legacy_panel:${createHash('sha256')
|
|
.update(token, 'utf8')
|
|
.digest('hex')}`,
|
|
authenticatedAtMs: ISSUED_AT_SECONDS * 1000,
|
|
expiresAtMs: EXPIRES_AT_SECONDS * 1000,
|
|
assurance: 'single_factor',
|
|
},
|
|
);
|
|
});
|
|
|
|
test('supports only the bounded legacy primary, string and TokenInfo list formats', async () => {
|
|
const token = signToken();
|
|
assert.equal(await sessionSource({ token }).isActive(token, 'mobile'), true);
|
|
assert.equal(
|
|
await sessionSource({ tokens: { desktop: token } }).isActive(
|
|
token,
|
|
'desktop',
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
await sessionSource({
|
|
tokens: { desktop: [{ value: token }] },
|
|
}).isActive(token, 'desktop'),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
await sessionSource({ tokens: { mobile: [{ value: token }] } }).isActive(
|
|
token,
|
|
'desktop',
|
|
),
|
|
false,
|
|
);
|
|
await assert.rejects(
|
|
sessionSource({
|
|
tokens: {
|
|
desktop: Array.from(
|
|
{ length: MAX_LEGACY_PANEL_TOKENS_PER_PLATFORM + 1 },
|
|
() => ({ value: token }),
|
|
),
|
|
},
|
|
}).isActive(token, 'desktop'),
|
|
LegacyPanelSessionUnavailableError,
|
|
);
|
|
await assert.rejects(
|
|
sessionSource({ tokens: { desktop: [{}] } }).isActive(token, 'desktop'),
|
|
LegacyPanelSessionUnavailableError,
|
|
);
|
|
});
|
|
|
|
test('rejects logout, platform drift, expiry, wrong signature and wrong algorithm', async (t) => {
|
|
const { directory } = await setup(t);
|
|
const token = signToken();
|
|
for (const [service, request] of [
|
|
[authentication(directory, { tokens: {} }), {}],
|
|
[
|
|
authentication(directory, {
|
|
tokens: { mobile: [{ value: token }] },
|
|
}),
|
|
{},
|
|
],
|
|
[
|
|
authentication(directory, {
|
|
tokens: { desktop: [{ value: token }] },
|
|
}),
|
|
{ nowMs: EXPIRES_AT_SECONDS * 1000 },
|
|
],
|
|
[
|
|
authentication(
|
|
directory,
|
|
{ tokens: { desktop: [{ value: token }] } },
|
|
'different-secret',
|
|
),
|
|
{},
|
|
],
|
|
[
|
|
authentication(directory, {
|
|
tokens: { desktop: [{ value: signToken(undefined, 'HS256') }] },
|
|
}),
|
|
{ token: signToken(undefined, 'HS256') },
|
|
],
|
|
]) {
|
|
await assert.rejects(
|
|
service.authenticate({
|
|
token,
|
|
platform: 'desktop',
|
|
nowMs: NOW_MS,
|
|
...request,
|
|
}),
|
|
LegacyPanelAuthenticationRejectedError,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('rejects extensible JWTs and request-supplied subjects before identity lookup', async (t) => {
|
|
const { directory } = await setup(t);
|
|
let sessionReads = 0;
|
|
const extraPayloadToken = signToken({
|
|
data: 'legacy-session-random-data',
|
|
iat: ISSUED_AT_SECONDS,
|
|
exp: EXPIRES_AT_SECONDS,
|
|
subject: 'attacker',
|
|
});
|
|
const service = new LegacyPanelAuthenticationService(
|
|
directory,
|
|
{
|
|
async isActive() {
|
|
sessionReads += 1;
|
|
return true;
|
|
},
|
|
},
|
|
SECRET,
|
|
);
|
|
await assert.rejects(
|
|
service.authenticate({
|
|
token: extraPayloadToken,
|
|
platform: 'desktop',
|
|
nowMs: NOW_MS,
|
|
}),
|
|
LegacyPanelAuthenticationRejectedError,
|
|
);
|
|
assert.equal(sessionReads, 0);
|
|
const futureIssuedToken = signToken({
|
|
data: 'legacy-session-random-data',
|
|
iat: ISSUED_AT_SECONDS + 60,
|
|
exp: EXPIRES_AT_SECONDS,
|
|
});
|
|
await assert.rejects(
|
|
service.authenticate({
|
|
token: futureIssuedToken,
|
|
platform: 'desktop',
|
|
nowMs: NOW_MS,
|
|
}),
|
|
LegacyPanelAuthenticationRejectedError,
|
|
);
|
|
assert.equal(sessionReads, 0);
|
|
await assert.rejects(
|
|
service.authenticate({
|
|
token: signToken(),
|
|
platform: 'desktop',
|
|
nowMs: NOW_MS,
|
|
subject: { type: 'user', id: 'attacker' },
|
|
}),
|
|
/request shape is invalid/,
|
|
);
|
|
assert.equal(sessionReads, 0);
|
|
});
|
|
|
|
test('revocation and subject disablement remove legacy authentication authority', async (t) => {
|
|
const revoked = await setup(t);
|
|
await revoked.database
|
|
.getQueryInterface()
|
|
.bulkInsert(IDENTITY_AUTHENTICATION_BINDING_TABLE, [
|
|
{
|
|
provider: LEGACY_PANEL_IDENTITY_PROVIDER,
|
|
provider_subject: LEGACY_PANEL_PROVIDER_SUBJECT,
|
|
version: 2,
|
|
state: 'revoked',
|
|
subject_id: LEGACY_PRIMARY_USER_SUBJECT_ID,
|
|
created_at_ms: NOW_MS,
|
|
},
|
|
]);
|
|
assert.equal(
|
|
await revoked.directory.resolveAuthenticationSubject(
|
|
LEGACY_PANEL_IDENTITY_PROVIDER,
|
|
LEGACY_PANEL_PROVIDER_SUBJECT,
|
|
),
|
|
null,
|
|
);
|
|
|
|
const disabled = await setup(t);
|
|
await disabled.database
|
|
.getQueryInterface()
|
|
.bulkUpdate(
|
|
IDENTITY_SUBJECT_TABLE,
|
|
{ status: 'disabled', version: 2, updated_at_ms: NOW_MS },
|
|
{ id: LEGACY_PRIMARY_USER_SUBJECT_ID },
|
|
);
|
|
const token = signToken();
|
|
await assert.rejects(
|
|
authentication(disabled.directory, {
|
|
tokens: { desktop: [{ value: token }] },
|
|
}).authenticate({ token, platform: 'desktop', nowMs: NOW_MS }),
|
|
LegacyPanelAuthenticationRejectedError,
|
|
);
|
|
});
|
|
|
|
test('fails closed on corrupt identity storage and session source failures', async (t) => {
|
|
const { database, directory } = await setup(t);
|
|
await database.query('PRAGMA ignore_check_constraints = ON');
|
|
await database
|
|
.getQueryInterface()
|
|
.bulkUpdate(
|
|
IDENTITY_AUTHENTICATION_BINDING_TABLE,
|
|
{ state: 'corrupt' },
|
|
{ provider: LEGACY_PANEL_IDENTITY_PROVIDER },
|
|
);
|
|
await assert.rejects(
|
|
directory.resolveAuthenticationSubject(
|
|
LEGACY_PANEL_IDENTITY_PROVIDER,
|
|
LEGACY_PANEL_PROVIDER_SUBJECT,
|
|
),
|
|
IdentityDirectoryUnavailableError,
|
|
);
|
|
|
|
const orphaned = await setup(t);
|
|
await orphaned.database.query('PRAGMA foreign_keys = OFF');
|
|
await orphaned.database
|
|
.getQueryInterface()
|
|
.bulkDelete(IDENTITY_SUBJECT_TABLE, {
|
|
id: LEGACY_PRIMARY_USER_SUBJECT_ID,
|
|
});
|
|
await assert.rejects(
|
|
orphaned.directory.resolveAuthenticationSubject(
|
|
LEGACY_PANEL_IDENTITY_PROVIDER,
|
|
LEGACY_PANEL_PROVIDER_SUBJECT,
|
|
),
|
|
IdentityDirectoryUnavailableError,
|
|
);
|
|
|
|
const token = signToken();
|
|
const service = new LegacyPanelAuthenticationService(
|
|
directory,
|
|
{
|
|
async isActive() {
|
|
throw new Error(`must not leak ${token}`);
|
|
},
|
|
},
|
|
SECRET,
|
|
);
|
|
await assert.rejects(
|
|
service.authenticate({ token, platform: 'desktop', nowMs: NOW_MS }),
|
|
(error) => {
|
|
assert.ok(error instanceof LegacyPanelAuthenticationUnavailableError);
|
|
assert.equal(error.message.includes(token), false);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('rejects non-SQLite identity directory repositories', () => {
|
|
assert.throws(
|
|
() =>
|
|
new LegacySequelizeIdentityDirectoryRepository({
|
|
getDialect() {
|
|
return 'postgres';
|
|
},
|
|
}),
|
|
/SQLite-only/,
|
|
);
|
|
});
|