mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { createHash } from 'crypto';
|
||||
import jwt, { type JwtPayload } from 'jsonwebtoken';
|
||||
import {
|
||||
assertAuthenticatedPrincipalActive,
|
||||
normalizeAuthenticatedPrincipal,
|
||||
type AuthenticatedPrincipal,
|
||||
} from '../domain/authenticatedPrincipal';
|
||||
import {
|
||||
IdentityDirectoryUnavailableError,
|
||||
LEGACY_PANEL_IDENTITY_PROVIDER,
|
||||
LEGACY_PANEL_PROVIDER_SUBJECT,
|
||||
} from '../domain/identityDirectory';
|
||||
import type { IdentityDirectoryRepository } from '../ports/identityDirectoryRepository';
|
||||
import type {
|
||||
LegacyPanelPlatform,
|
||||
LegacyPanelSessionSource,
|
||||
} from '../ports/legacyPanelSessionSource';
|
||||
|
||||
const LEGACY_PANEL_JWT_PATTERN =
|
||||
/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
||||
const MAX_LEGACY_PANEL_TOKEN_LENGTH = 4096;
|
||||
const MAX_LEGACY_PANEL_JWT_DATA_LENGTH = 256;
|
||||
|
||||
export interface AuthenticateLegacyPanelSessionRequest {
|
||||
token: string;
|
||||
platform: LegacyPanelPlatform;
|
||||
nowMs: number;
|
||||
}
|
||||
|
||||
export class LegacyPanelAuthenticationRejectedError extends Error {
|
||||
readonly code = 'LEGACY_PANEL_AUTHENTICATION_REJECTED';
|
||||
|
||||
constructor() {
|
||||
super('Legacy panel authentication was rejected');
|
||||
this.name = 'LegacyPanelAuthenticationRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LegacyPanelAuthenticationUnavailableError extends Error {
|
||||
readonly code = 'LEGACY_PANEL_AUTHENTICATION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Legacy panel authentication is unavailable');
|
||||
this.name = 'LegacyPanelAuthenticationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactRequest(request: AuthenticateLegacyPanelSessionRequest) {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new TypeError('Legacy panel authentication request is invalid');
|
||||
}
|
||||
const keys = Object.keys(request).sort();
|
||||
const expected = ['nowMs', 'platform', 'token'];
|
||||
if (
|
||||
keys.length !== expected.length ||
|
||||
keys.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new TypeError('Legacy panel authentication request shape is invalid');
|
||||
}
|
||||
if (
|
||||
typeof request.token !== 'string' ||
|
||||
request.token.length < 1 ||
|
||||
request.token.length > MAX_LEGACY_PANEL_TOKEN_LENGTH ||
|
||||
!LEGACY_PANEL_JWT_PATTERN.test(request.token)
|
||||
) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
if (request.platform !== 'desktop' && request.platform !== 'mobile') {
|
||||
throw new TypeError('Legacy panel platform is invalid');
|
||||
}
|
||||
if (!Number.isSafeInteger(request.nowMs) || request.nowMs < 0) {
|
||||
throw new TypeError('Legacy panel authentication time is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePayload(value: string | JwtPayload): {
|
||||
authenticatedAtMs: number;
|
||||
expiresAtMs: number;
|
||||
} {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const expected = ['data', 'exp', 'iat'];
|
||||
if (
|
||||
keys.length !== expected.length ||
|
||||
keys.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
if (
|
||||
typeof value.data !== 'string' ||
|
||||
value.data.length < 1 ||
|
||||
value.data.length > MAX_LEGACY_PANEL_JWT_DATA_LENGTH ||
|
||||
!Number.isSafeInteger(value.iat) ||
|
||||
value.iat! < 0 ||
|
||||
!Number.isSafeInteger(value.exp) ||
|
||||
value.exp! <= value.iat!
|
||||
) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
const authenticatedAtMs = value.iat! * 1000;
|
||||
const expiresAtMs = value.exp! * 1000;
|
||||
if (
|
||||
!Number.isSafeInteger(authenticatedAtMs) ||
|
||||
!Number.isSafeInteger(expiresAtMs)
|
||||
) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
return { authenticatedAtMs, expiresAtMs };
|
||||
}
|
||||
|
||||
export class LegacyPanelAuthenticationService {
|
||||
constructor(
|
||||
private readonly identityDirectory: IdentityDirectoryRepository,
|
||||
private readonly sessions: LegacyPanelSessionSource,
|
||||
private readonly jwtSecret: string,
|
||||
) {
|
||||
if (
|
||||
typeof jwtSecret !== 'string' ||
|
||||
jwtSecret.length < 1 ||
|
||||
jwtSecret.length > 4096
|
||||
) {
|
||||
throw new TypeError('Legacy panel JWT secret is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(
|
||||
request: AuthenticateLegacyPanelSessionRequest,
|
||||
): Promise<Readonly<AuthenticatedPrincipal>> {
|
||||
assertExactRequest(request);
|
||||
let payload: { authenticatedAtMs: number; expiresAtMs: number };
|
||||
try {
|
||||
payload = normalizePayload(
|
||||
jwt.verify(request.token, this.jwtSecret, {
|
||||
algorithms: ['HS384'],
|
||||
clockTimestamp: Math.floor(request.nowMs / 1000),
|
||||
clockTolerance: 0,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
if (
|
||||
payload.authenticatedAtMs > request.nowMs ||
|
||||
payload.expiresAtMs <= request.nowMs
|
||||
) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
|
||||
let active: boolean;
|
||||
try {
|
||||
active = await this.sessions.isActive(request.token, request.platform);
|
||||
} catch {
|
||||
throw new LegacyPanelAuthenticationUnavailableError();
|
||||
}
|
||||
if (!active) throw new LegacyPanelAuthenticationRejectedError();
|
||||
|
||||
let subject;
|
||||
try {
|
||||
subject = await this.identityDirectory.resolveAuthenticationSubject(
|
||||
LEGACY_PANEL_IDENTITY_PROVIDER,
|
||||
LEGACY_PANEL_PROVIDER_SUBJECT,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof IdentityDirectoryUnavailableError) {
|
||||
throw new LegacyPanelAuthenticationUnavailableError();
|
||||
}
|
||||
throw new LegacyPanelAuthenticationUnavailableError();
|
||||
}
|
||||
if (!subject || subject.type !== 'user') {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
|
||||
const principal = normalizeAuthenticatedPrincipal({
|
||||
subject,
|
||||
authenticationId: `legacy_panel:${createHash('sha256')
|
||||
.update(request.token, 'utf8')
|
||||
.digest('hex')}`,
|
||||
authenticatedAtMs: payload.authenticatedAtMs,
|
||||
expiresAtMs: payload.expiresAtMs,
|
||||
assurance: 'single_factor',
|
||||
});
|
||||
try {
|
||||
assertAuthenticatedPrincipalActive(principal, request.nowMs);
|
||||
} catch {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user