cangku/backend/src/services/thirdPartySsoDirectService.js

374 lines
14 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const database = require('../config/database');
const { getResolvedUserWithPageAccess } = require('./pageAccess');
const {
SUPPORTED_SCOPES,
TOKEN_ISSUER,
ThirdPartySsoError,
authenticateClient,
buildUserInfo,
createSsoClient,
hashValue,
normalizeRedirectUri,
rotateSsoClientSecret,
updateSsoClient,
} = require('./thirdPartySsoService');
const DIRECT_LOGIN_FLOW = 'third_party_sso_direct';
const DIRECT_LOGIN_SESSION_TTL_SECONDS = Math.min(
Math.max(Number.parseInt(process.env.THIRD_PARTY_SSO_DIRECT_TTL_SECONDS || '300', 10), 120),
900
);
const DIRECT_CLIENT_REDIRECT_URI = `${TOKEN_ISSUER}/direct/complete`;
function normalizeDirectReturnUrl(value) {
const input = String(value || '').trim();
if (!input || input.length > 1024) {
throw new ThirdPartySsoError('return_url 无效', 400, 'invalid_request');
}
let parsed;
try {
parsed = new URL(input);
} catch (_error) {
throw new ThirdPartySsoError('return_url 格式无效', 400, 'invalid_request');
}
if (parsed.username || parsed.password || parsed.hash) {
throw new ThirdPartySsoError('return_url 不能包含用户信息或锚点', 400, 'invalid_request');
}
// Hosted login only returns an opaque request id. Exact allowlisting keeps
// legacy HTTP partner sites usable without weakening the standard OAuth flow.
if (parsed.protocol === 'http:') return parsed.toString();
return normalizeRedirectUri(input);
}
function normalizeDirectReturnUrls(value) {
const rawUrls = Array.isArray(value) ? value : (value ? [value] : []);
const returnUrls = [...new Set(rawUrls.map(normalizeDirectReturnUrl))]
.filter((url) => url !== DIRECT_CLIENT_REDIRECT_URI);
if (returnUrls.length > 19) {
throw new ThirdPartySsoError('最多配置 19 个第三方回跳地址', 400, 'invalid_request');
}
return returnUrls;
}
function getDirectReturnUrls(client) {
return (client.redirectUris || []).filter((url) => url !== DIRECT_CLIENT_REDIRECT_URI);
}
function validateDirectReturnUrl(value, client) {
if (!value) return '';
const returnUrl = normalizeDirectReturnUrl(value);
if (!getDirectReturnUrls(client).includes(returnUrl)) {
throw new ThirdPartySsoError('return_url 未在该应用白名单中', 400, 'invalid_request');
}
return returnUrl;
}
function buildDirectCompletionRedirect(returnUrl, requestId) {
if (!returnUrl) return '';
const redirect = new URL(returnUrl);
redirect.searchParams.set('sso_request_id', requestId);
redirect.searchParams.set('sso_status', 'completed');
return redirect.toString();
}
function normalizeDirectScopes(value, allowedScopes = ['profile']) {
const requested = Array.isArray(value)
? value
: String(value || 'profile').split(/\s+/u).filter(Boolean);
const scopes = [...new Set(requested.map((scope) => String(scope).trim()).filter(Boolean))];
const unsupported = scopes.filter((scope) => !SUPPORTED_SCOPES.includes(scope));
if (unsupported.length) {
throw new ThirdPartySsoError(`不支持的 scope: ${unsupported.join(', ')}`, 400, 'invalid_scope');
}
const disallowed = scopes.filter((scope) => !allowedScopes.includes(scope));
if (disallowed.length) {
throw new ThirdPartySsoError(`应用未授权 scope: ${disallowed.join(', ')}`, 400, 'invalid_scope');
}
return scopes.length ? scopes : ['profile'];
}
function formatDirectApiKey(clientId, clientSecret) {
return `ddak_${clientId}.${clientSecret}`;
}
function parseDirectApiKey(value) {
const match = String(value || '').trim().match(
/^ddak_(sso_[A-Za-z0-9_-]{20,76})\.([A-Za-z0-9_-]{40,100})$/u
);
if (!match) {
throw new ThirdPartySsoError('API Key 无效', 401, 'invalid_client');
}
return { clientId: match[1], clientSecret: match[2] };
}
function normalizeRequestId(value) {
const requestId = String(value || '').trim();
if (!/^dls_[A-Za-z0-9_-]{40,100}$/u.test(requestId)) {
throw new ThirdPartySsoError('登录任务无效', 400, 'invalid_request');
}
return requestId;
}
function serializeDirectSession(row) {
if (!row) return null;
return {
sessionHash: row.session_hash || row.sessionHash,
clientId: row.client_id || row.clientId,
userId: row.user_id || row.userId || null,
scope: row.scope || 'profile',
status: row.status || 'pending',
returnUrl: row.return_url || row.returnUrl || '',
expiresAt: row.expires_at || row.expiresAt,
authorizedAt: row.authorized_at || row.authorizedAt || null,
consumedAt: row.consumed_at || row.consumedAt || null,
};
}
const defaultStore = {
async insertDirectSession(record) {
await database.local.insert(
`INSERT INTO third_party_sso_direct_sessions
(session_hash, client_id, scope, return_url, status, expires_at)
VALUES (?, ?, ?, ?, 'pending', ?)`,
[record.sessionHash, record.clientId, record.scope, record.returnUrl || null, new Date(record.expiresAt)]
);
},
async findDirectSession(sessionHash, clientId = '') {
const clientClause = clientId ? ' AND client_id = ?' : '';
const params = clientId ? [sessionHash, clientId] : [sessionHash];
const rows = await database.local.query(
`SELECT session_hash AS sessionHash, client_id AS clientId, user_id AS userId,
scope, return_url AS returnUrl, status, expires_at AS expiresAt, authorized_at AS authorizedAt,
consumed_at AS consumedAt
FROM third_party_sso_direct_sessions
WHERE session_hash = ?${clientClause}
LIMIT 1`,
params
);
return serializeDirectSession(rows[0]);
},
async completeDirectSession(sessionHash, userId) {
return database.local.update(
`UPDATE third_party_sso_direct_sessions
SET status = 'completed', user_id = ?, authorized_at = NOW()
WHERE session_hash = ? AND status = 'pending' AND expires_at > NOW()`,
[userId, sessionHash]
);
},
async consumeDirectSession(sessionHash, clientId) {
return database.local.update(
`UPDATE third_party_sso_direct_sessions
SET status = 'consumed', consumed_at = NOW()
WHERE session_hash = ? AND client_id = ? AND status = 'completed'`,
[sessionHash, clientId]
);
},
async cleanupExpiredDirectSessions() {
await database.local.query(
`DELETE FROM third_party_sso_direct_sessions
WHERE expires_at < DATE_SUB(NOW(), INTERVAL 1 DAY)`
);
},
};
async function authenticateDirectApiKey(apiKey, options = {}) {
const credentials = parseDirectApiKey(apiKey);
return authenticateClient(credentials, options);
}
async function createDirectLoginSession(input = {}, apiKey = '', options = {}) {
const client = await authenticateDirectApiKey(apiKey, options);
const scopes = normalizeDirectScopes(input.scope || input.scopes, client.allowedScopes);
const returnUrl = validateDirectReturnUrl(input.returnUrl || input.return_url, client);
const requestId = `dls_${crypto.randomBytes(32).toString('base64url')}`;
const expiresAt = Date.now() + DIRECT_LOGIN_SESSION_TTL_SECONDS * 1000;
const store = options.directStore || defaultStore;
await store.insertDirectSession({
sessionHash: hashValue(requestId),
clientId: client.clientId,
scope: scopes.join(' '),
returnUrl,
expiresAt,
});
void store.cleanupExpiredDirectSessions?.().catch(() => {});
return {
request_id: requestId,
status: 'pending',
login_url: `${TOKEN_ISSUER}/direct/login?request_id=${encodeURIComponent(requestId)}`,
return_url: returnUrl || undefined,
expires_in: DIRECT_LOGIN_SESSION_TTL_SECONDS,
};
}
async function beginDirectLogin(requestId, options = {}) {
const normalized = normalizeRequestId(requestId);
const store = options.directStore || defaultStore;
const session = await store.findDirectSession(hashValue(normalized));
if (!session || session.status !== 'pending' || new Date(session.expiresAt).getTime() <= Date.now()) {
throw new ThirdPartySsoError('登录任务不存在或已过期', 410, 'invalid_request');
}
return {
session,
stateClaims: {
flow: DIRECT_LOGIN_FLOW,
directRequestId: normalized,
},
};
}
async function completeDirectLogin({ stateClaims, userId }, options = {}) {
if (!stateClaims || stateClaims.flow !== DIRECT_LOGIN_FLOW) {
throw new ThirdPartySsoError('登录任务无效或已过期', 400, 'invalid_request');
}
const requestId = normalizeRequestId(stateClaims.directRequestId);
const store = options.directStore || defaultStore;
const session = await store.findDirectSession(hashValue(requestId));
if (!session || session.status !== 'pending' || new Date(session.expiresAt).getTime() <= Date.now()) {
throw new ThirdPartySsoError('登录任务已完成、过期或不存在', 410, 'invalid_request');
}
const updated = await store.completeDirectSession(hashValue(requestId), Number(userId));
if (Number(updated) !== 1) {
throw new ThirdPartySsoError('登录任务已完成、过期或不存在', 410, 'invalid_request');
}
return {
requestId,
completionRedirect: buildDirectCompletionRedirect(session.returnUrl, requestId),
};
}
async function pollDirectLoginSession(requestId, apiKey = '', options = {}) {
const normalized = normalizeRequestId(requestId);
const client = await authenticateDirectApiKey(apiKey, options);
const store = options.directStore || defaultStore;
const session = await store.findDirectSession(hashValue(normalized), client.clientId);
if (!session) {
throw new ThirdPartySsoError('登录任务不存在', 404, 'invalid_request');
}
if (new Date(session.expiresAt).getTime() <= Date.now() && session.status === 'pending') {
return { request_id: normalized, status: 'expired' };
}
if (session.status !== 'completed') {
return { request_id: normalized, status: session.status };
}
const resolveUser = options.resolveUser || getResolvedUserWithPageAccess;
const user = await resolveUser(session.userId);
if (!user || (user.status && user.status !== 'active')) {
throw new ThirdPartySsoError('用户不存在或已停用', 403, 'access_denied');
}
const consumed = await store.consumeDirectSession(hashValue(normalized), client.clientId);
if (Number(consumed) !== 1) {
return { request_id: normalized, status: 'consumed' };
}
return {
request_id: normalized,
status: 'completed',
user: buildUserInfo({ user, scopes: normalizeDirectScopes(session.scope, client.allowedScopes) }),
};
}
async function createDirectApiKey(input = {}, actor = {}, options = {}) {
const allowedReturnUrls = normalizeDirectReturnUrls(
input.allowedReturnUrls || input.returnUrls || input.redirectUris
);
const created = await createSsoClient({
clientName: input.clientName || input.name,
clientType: 'confidential',
redirectUris: [DIRECT_CLIENT_REDIRECT_URI, ...allowedReturnUrls],
allowedScopes: input.allowedScopes || ['profile', 'email', 'department'],
}, actor, { ...options, redirectUriNormalizer: normalizeDirectReturnUrl });
return {
clientId: created.clientId,
clientName: created.clientName,
allowedScopes: created.allowedScopes,
allowedReturnUrls,
status: created.status,
apiKey: formatDirectApiKey(created.clientId, created.clientSecret),
};
}
async function rotateDirectApiKey(clientId, actor = {}, options = {}) {
const rotated = await rotateSsoClientSecret(clientId, actor, options);
return {
clientId: rotated.clientId,
clientName: rotated.clientName,
allowedScopes: rotated.allowedScopes,
allowedReturnUrls: getDirectReturnUrls(rotated),
status: rotated.status,
apiKey: formatDirectApiKey(rotated.clientId, rotated.clientSecret),
};
}
async function updateDirectReturnUrls(clientId, input = {}, actor = {}, options = {}) {
const allowedReturnUrls = normalizeDirectReturnUrls(
input.allowedReturnUrls || input.returnUrls || input.redirectUris
);
const updated = await updateSsoClient(clientId, {
redirectUris: [DIRECT_CLIENT_REDIRECT_URI, ...allowedReturnUrls],
}, actor, { ...options, redirectUriNormalizer: normalizeDirectReturnUrl });
return {
clientId: updated.clientId,
clientName: updated.clientName,
allowedReturnUrls,
status: updated.status,
};
}
async function ensureThirdPartySsoDirectSchema() {
await database.local.query(
`CREATE TABLE IF NOT EXISTS third_party_sso_direct_sessions (
session_hash CHAR(64) PRIMARY KEY,
client_id VARCHAR(80) NOT NULL,
user_id BIGINT NULL,
scope VARCHAR(500) NOT NULL DEFAULT 'profile',
return_url VARCHAR(1024) NULL,
status ENUM('pending', 'completed', 'consumed') NOT NULL DEFAULT 'pending',
expires_at DATETIME NOT NULL,
authorized_at DATETIME NULL,
consumed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_third_party_sso_direct_client_status (client_id, status),
KEY idx_third_party_sso_direct_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方 API Key 托管登录任务'`
);
const columns = await database.local.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'third_party_sso_direct_sessions'
AND column_name = 'return_url'
LIMIT 1`
);
if (!columns.length) {
await database.local.query(
`ALTER TABLE third_party_sso_direct_sessions
ADD COLUMN return_url VARCHAR(1024) NULL AFTER scope`
);
}
}
module.exports = {
DIRECT_LOGIN_FLOW,
DIRECT_LOGIN_SESSION_TTL_SECONDS,
authenticateDirectApiKey,
beginDirectLogin,
completeDirectLogin,
createDirectApiKey,
createDirectLoginSession,
ensureThirdPartySsoDirectSchema,
formatDirectApiKey,
parseDirectApiKey,
pollDirectLoginSession,
rotateDirectApiKey,
updateDirectReturnUrls,
};