374 lines
14 KiB
JavaScript
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,
|
|
};
|