forked from lunanting/cangku
831 lines
28 KiB
JavaScript
831 lines
28 KiB
JavaScript
'use strict';
|
||
|
||
const crypto = require('crypto');
|
||
const jwt = require('jsonwebtoken');
|
||
const database = require('../config/database');
|
||
const { getResolvedUserWithPageAccess } = require('./pageAccess');
|
||
|
||
const THIRD_PARTY_FLOW = 'third_party_sso';
|
||
const TOKEN_TYPE = 'third-party-sso';
|
||
const TOKEN_ISSUER = String(
|
||
process.env.THIRD_PARTY_SSO_ISSUER || 'https://pddg.jiansuntuzhi.cn/api/sso'
|
||
).replace(/\/$/u, '');
|
||
const AUTHORIZATION_CODE_TTL_SECONDS = Math.min(
|
||
Math.max(Number.parseInt(process.env.THIRD_PARTY_SSO_CODE_TTL_SECONDS || '60', 10), 30),
|
||
300
|
||
);
|
||
const ACCESS_TOKEN_TTL_SECONDS = Math.min(
|
||
Math.max(Number.parseInt(process.env.THIRD_PARTY_SSO_ACCESS_TOKEN_TTL_SECONDS || '900', 10), 300),
|
||
3600
|
||
);
|
||
const SUPPORTED_SCOPES = Object.freeze(['profile', 'email', 'department', 'permissions']);
|
||
|
||
function getThirdPartySsoCallbackBaseUrl() {
|
||
try {
|
||
return new URL(TOKEN_ISSUER).origin;
|
||
} catch (_error) {
|
||
throw new ThirdPartySsoError('统一认证服务地址配置无效', 503, 'server_error');
|
||
}
|
||
}
|
||
|
||
class ThirdPartySsoError extends Error {
|
||
constructor(message, status = 400, code = 'invalid_request') {
|
||
super(message);
|
||
this.name = 'ThirdPartySsoError';
|
||
this.status = status;
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
function hashValue(value) {
|
||
return crypto.createHash('sha256').update(String(value || '')).digest('hex');
|
||
}
|
||
|
||
function timingSafeEqualText(left, right) {
|
||
const leftBuffer = Buffer.from(String(left || ''));
|
||
const rightBuffer = Buffer.from(String(right || ''));
|
||
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||
}
|
||
|
||
function getSigningSecret() {
|
||
const dedicatedSecret = String(process.env.THIRD_PARTY_SSO_SIGNING_SECRET || '').trim();
|
||
if (dedicatedSecret) return dedicatedSecret;
|
||
|
||
const applicationSecret = String(process.env.CUBEJS_SECRET || '').trim();
|
||
if (!applicationSecret) {
|
||
throw new ThirdPartySsoError('统一认证签名密钥未配置', 503, 'server_error');
|
||
}
|
||
|
||
return crypto
|
||
.createHmac('sha256', applicationSecret)
|
||
.update('third-party-sso-signing-key-v1')
|
||
.digest('hex');
|
||
}
|
||
|
||
function parseJsonList(value) {
|
||
if (Array.isArray(value)) return value;
|
||
if (!value) return [];
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch (_error) {
|
||
return String(value).split(/[\s,]+/u).filter(Boolean);
|
||
}
|
||
}
|
||
|
||
function normalizeClientId(value) {
|
||
const clientId = String(value || '').trim();
|
||
if (!/^[A-Za-z0-9._-]{8,80}$/u.test(clientId)) {
|
||
throw new ThirdPartySsoError('client_id 无效', 400, 'invalid_client');
|
||
}
|
||
return clientId;
|
||
}
|
||
|
||
function normalizeRedirectUri(value) {
|
||
const input = String(value || '').trim();
|
||
if (!input || input.length > 1024) {
|
||
throw new ThirdPartySsoError('redirect_uri 无效', 400, 'invalid_request');
|
||
}
|
||
|
||
let parsed;
|
||
try {
|
||
parsed = new URL(input);
|
||
} catch (_error) {
|
||
throw new ThirdPartySsoError('redirect_uri 格式无效', 400, 'invalid_request');
|
||
}
|
||
|
||
if (parsed.username || parsed.password || parsed.hash) {
|
||
throw new ThirdPartySsoError('redirect_uri 不能包含用户信息或锚点', 400, 'invalid_request');
|
||
}
|
||
|
||
const protocol = parsed.protocol.toLowerCase();
|
||
const isHttps = protocol === 'https:';
|
||
const isLoopbackHttp = protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
||
const isCustomScheme = /^[a-z][a-z0-9+.-]*:$/u.test(protocol)
|
||
&& !['javascript:', 'data:', 'file:', 'http:', 'https:'].includes(protocol);
|
||
if (!isHttps && !isLoopbackHttp && !isCustomScheme) {
|
||
throw new ThirdPartySsoError('redirect_uri 必须使用 HTTPS、本机 HTTP 或已登记的 App Scheme', 400, 'invalid_request');
|
||
}
|
||
|
||
return parsed.toString();
|
||
}
|
||
|
||
function normalizeStoredRedirectUri(value) {
|
||
const input = String(value || '').trim();
|
||
if (!input || input.length > 1024) {
|
||
throw new ThirdPartySsoError('redirect_uri 无效', 400, 'invalid_request');
|
||
}
|
||
let parsed;
|
||
try {
|
||
parsed = new URL(input);
|
||
} catch (_error) {
|
||
throw new ThirdPartySsoError('redirect_uri 格式无效', 400, 'invalid_request');
|
||
}
|
||
if (parsed.username || parsed.password || parsed.hash) {
|
||
throw new ThirdPartySsoError('redirect_uri 不能包含用户信息或锚点', 400, 'invalid_request');
|
||
}
|
||
return parsed.toString();
|
||
}
|
||
|
||
function normalizeRedirectUris(values, normalizeUri = normalizeRedirectUri) {
|
||
const uris = [...new Set(parseJsonList(values).map(normalizeUri))];
|
||
if (uris.length === 0 || uris.length > 20) {
|
||
throw new ThirdPartySsoError('请配置 1 至 20 个回调地址', 400, 'invalid_request');
|
||
}
|
||
return uris;
|
||
}
|
||
|
||
function normalizeScopes(value, fallback = ['profile']) {
|
||
const rawScopes = Array.isArray(value)
|
||
? value
|
||
: String(value || '').split(/\s+/u).filter(Boolean);
|
||
const scopes = [...new Set(rawScopes.length ? rawScopes : fallback)]
|
||
.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');
|
||
}
|
||
return scopes;
|
||
}
|
||
|
||
function normalizeCodeChallenge(value) {
|
||
const challenge = String(value || '').trim();
|
||
if (!/^[A-Za-z0-9_-]{43,128}$/u.test(challenge)) {
|
||
throw new ThirdPartySsoError('缺少有效的 PKCE code_challenge', 400, 'invalid_request');
|
||
}
|
||
return challenge;
|
||
}
|
||
|
||
function normalizeCodeVerifier(value) {
|
||
const verifier = String(value || '').trim();
|
||
if (!/^[A-Za-z0-9._~-]{43,128}$/u.test(verifier)) {
|
||
throw new ThirdPartySsoError('PKCE code_verifier 无效', 400, 'invalid_grant');
|
||
}
|
||
return verifier;
|
||
}
|
||
|
||
function buildCodeChallenge(verifier) {
|
||
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
||
}
|
||
|
||
function serializeClient(row) {
|
||
if (!row) return null;
|
||
return {
|
||
id: Number(row.id),
|
||
clientId: row.client_id || row.clientId,
|
||
clientName: row.client_name || row.clientName,
|
||
clientType: row.client_type || row.clientType || 'confidential',
|
||
clientSecretHash: row.client_secret_hash || row.clientSecretHash || '',
|
||
// Stored direct-login return URLs may be explicitly allowlisted HTTP URLs.
|
||
// Authorization requests still pass through normalizeRedirectUri below.
|
||
redirectUris: normalizeRedirectUris(
|
||
row.redirect_uris || row.redirectUris,
|
||
normalizeStoredRedirectUri
|
||
),
|
||
allowedScopes: normalizeScopes(parseJsonList(row.allowed_scopes || row.allowedScopes)),
|
||
status: row.status || 'active',
|
||
createdBy: row.created_by || row.createdBy || null,
|
||
lastUsedAt: row.last_used_at || row.lastUsedAt || null,
|
||
createdAt: row.created_at || row.createdAt || null,
|
||
updatedAt: row.updated_at || row.updatedAt || null,
|
||
};
|
||
}
|
||
|
||
const defaultStore = {
|
||
async findClient(clientId) {
|
||
const rows = await database.local.query(
|
||
`SELECT * FROM third_party_sso_clients WHERE client_id = ? LIMIT 1`,
|
||
[clientId]
|
||
);
|
||
return serializeClient(rows[0]);
|
||
},
|
||
|
||
async listClients() {
|
||
const rows = await database.local.query(
|
||
`SELECT * FROM third_party_sso_clients ORDER BY created_at DESC, id DESC`
|
||
);
|
||
return rows.map(serializeClient);
|
||
},
|
||
|
||
async createClient(record) {
|
||
await database.local.insert(
|
||
`INSERT INTO third_party_sso_clients
|
||
(client_id, client_name, client_type, client_secret_hash, redirect_uris, allowed_scopes, status, created_by)
|
||
VALUES (?, ?, ?, ?, ?, ?, 'active', ?)`,
|
||
[
|
||
record.clientId,
|
||
record.clientName,
|
||
record.clientType,
|
||
record.clientSecretHash || null,
|
||
JSON.stringify(record.redirectUris),
|
||
JSON.stringify(record.allowedScopes),
|
||
record.createdBy || null,
|
||
]
|
||
);
|
||
return this.findClient(record.clientId);
|
||
},
|
||
|
||
async updateClient(clientId, changes) {
|
||
const columns = [];
|
||
const params = [];
|
||
const mapping = {
|
||
clientName: 'client_name',
|
||
clientSecretHash: 'client_secret_hash',
|
||
redirectUris: 'redirect_uris',
|
||
allowedScopes: 'allowed_scopes',
|
||
status: 'status',
|
||
};
|
||
for (const [key, column] of Object.entries(mapping)) {
|
||
if (changes[key] === undefined) continue;
|
||
columns.push(`${column} = ?`);
|
||
params.push(['redirectUris', 'allowedScopes'].includes(key) ? JSON.stringify(changes[key]) : changes[key]);
|
||
}
|
||
if (!columns.length) return this.findClient(clientId);
|
||
params.push(clientId);
|
||
await database.local.update(
|
||
`UPDATE third_party_sso_clients SET ${columns.join(', ')} WHERE client_id = ?`,
|
||
params
|
||
);
|
||
return this.findClient(clientId);
|
||
},
|
||
|
||
async insertAuthorizationCode(record) {
|
||
await database.local.insert(
|
||
`INSERT INTO third_party_sso_authorization_codes
|
||
(code_hash, client_id, user_id, redirect_uri, scope, code_challenge, expires_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
[
|
||
record.codeHash,
|
||
record.clientId,
|
||
record.userId,
|
||
record.redirectUri,
|
||
record.scope,
|
||
record.codeChallenge,
|
||
new Date(record.expiresAt),
|
||
]
|
||
);
|
||
},
|
||
|
||
async findAuthorizationCode(codeHash, clientId) {
|
||
const rows = await database.local.query(
|
||
`SELECT code_hash AS codeHash, client_id AS clientId, user_id AS userId,
|
||
redirect_uri AS redirectUri, scope, code_challenge AS codeChallenge,
|
||
expires_at AS expiresAt, consumed_at AS consumedAt
|
||
FROM third_party_sso_authorization_codes
|
||
WHERE code_hash = ? AND client_id = ?
|
||
LIMIT 1`,
|
||
[codeHash, clientId]
|
||
);
|
||
return rows[0] || null;
|
||
},
|
||
|
||
async consumeAuthorizationCode(codeHash, clientId) {
|
||
return database.local.update(
|
||
`UPDATE third_party_sso_authorization_codes
|
||
SET consumed_at = NOW()
|
||
WHERE code_hash = ? AND client_id = ? AND consumed_at IS NULL`,
|
||
[codeHash, clientId]
|
||
);
|
||
},
|
||
|
||
async touchClient(clientId) {
|
||
await database.local.update(
|
||
`UPDATE third_party_sso_clients SET last_used_at = NOW() WHERE client_id = ?`,
|
||
[clientId]
|
||
);
|
||
},
|
||
|
||
async isTokenRevoked(jti) {
|
||
const rows = await database.local.query(
|
||
`SELECT jti_hash FROM third_party_sso_revoked_tokens
|
||
WHERE jti_hash = ? AND expires_at > NOW()
|
||
LIMIT 1`,
|
||
[hashValue(jti)]
|
||
);
|
||
return Boolean(rows[0]);
|
||
},
|
||
|
||
async revokeToken(record) {
|
||
await database.local.query(
|
||
`INSERT INTO third_party_sso_revoked_tokens
|
||
(jti_hash, client_id, user_id, expires_at)
|
||
VALUES (?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at)`,
|
||
[hashValue(record.jti), record.clientId, record.userId, new Date(record.expiresAt)]
|
||
);
|
||
},
|
||
|
||
async audit(record) {
|
||
await database.local.insert(
|
||
`INSERT INTO third_party_sso_audit_logs
|
||
(event_type, client_id, user_id, result, ip_address, user_agent, detail)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
[
|
||
record.eventType,
|
||
record.clientId || null,
|
||
record.userId || null,
|
||
record.result || 'success',
|
||
String(record.ipAddress || '').slice(0, 64) || null,
|
||
String(record.userAgent || '').slice(0, 500) || null,
|
||
record.detail ? JSON.stringify(record.detail) : null,
|
||
]
|
||
);
|
||
},
|
||
|
||
async cleanupExpired() {
|
||
await database.local.query(
|
||
`DELETE FROM third_party_sso_authorization_codes
|
||
WHERE expires_at < DATE_SUB(NOW(), INTERVAL 1 DAY)`
|
||
);
|
||
await database.local.query(
|
||
`DELETE FROM third_party_sso_revoked_tokens WHERE expires_at < NOW()`
|
||
);
|
||
},
|
||
};
|
||
|
||
async function auditSafely(store, record) {
|
||
try {
|
||
await store.audit(record);
|
||
} catch (error) {
|
||
console.warn('[ThirdPartySSO] 写入审计日志失败:', error.message);
|
||
}
|
||
}
|
||
|
||
async function requireActiveClient(clientId, options = {}) {
|
||
const normalizedClientId = normalizeClientId(clientId);
|
||
const store = options.store || defaultStore;
|
||
const client = await store.findClient(normalizedClientId);
|
||
if (!client || client.status !== 'active') {
|
||
throw new ThirdPartySsoError('第三方应用不存在或已停用', 401, 'invalid_client');
|
||
}
|
||
return client;
|
||
}
|
||
|
||
async function validateAuthorizationRequest(input = {}, options = {}) {
|
||
if (String(input.responseType || input.response_type || '') !== 'code') {
|
||
throw new ThirdPartySsoError('仅支持 response_type=code', 400, 'unsupported_response_type');
|
||
}
|
||
|
||
const client = await requireActiveClient(input.clientId || input.client_id, options);
|
||
const redirectUri = normalizeRedirectUri(input.redirectUri || input.redirect_uri);
|
||
if (!client.redirectUris.includes(redirectUri)) {
|
||
throw new ThirdPartySsoError('redirect_uri 未在该应用白名单中', 400, 'invalid_request');
|
||
}
|
||
|
||
const requestedScopes = normalizeScopes(input.scope);
|
||
const disallowedScopes = requestedScopes.filter((scope) => !client.allowedScopes.includes(scope));
|
||
if (disallowedScopes.length) {
|
||
throw new ThirdPartySsoError(`应用未授权 scope: ${disallowedScopes.join(', ')}`, 400, 'invalid_scope');
|
||
}
|
||
|
||
const clientState = String(input.state || '').trim();
|
||
if (!clientState || clientState.length > 512) {
|
||
throw new ThirdPartySsoError('缺少有效的 state', 400, 'invalid_request');
|
||
}
|
||
|
||
if (String(input.codeChallengeMethod || input.code_challenge_method || '') !== 'S256') {
|
||
throw new ThirdPartySsoError('仅支持 PKCE S256', 400, 'invalid_request');
|
||
}
|
||
const codeChallenge = normalizeCodeChallenge(input.codeChallenge || input.code_challenge);
|
||
|
||
return {
|
||
client,
|
||
redirectUri,
|
||
scopes: requestedScopes,
|
||
clientState,
|
||
codeChallenge,
|
||
stateClaims: {
|
||
flow: THIRD_PARTY_FLOW,
|
||
ssoClientId: client.clientId,
|
||
ssoRedirectUri: redirectUri,
|
||
ssoClientState: clientState,
|
||
ssoScope: requestedScopes.join(' '),
|
||
ssoCodeChallenge: codeChallenge,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function completeThirdPartyAuthorization({ stateClaims, userId, requestContext = {} }, options = {}) {
|
||
if (!stateClaims || stateClaims.flow !== THIRD_PARTY_FLOW) {
|
||
throw new ThirdPartySsoError('授权事务无效或已过期', 400, 'invalid_request');
|
||
}
|
||
|
||
const store = options.store || defaultStore;
|
||
const validated = await validateAuthorizationRequest({
|
||
responseType: 'code',
|
||
clientId: stateClaims.ssoClientId,
|
||
redirectUri: stateClaims.ssoRedirectUri,
|
||
state: stateClaims.ssoClientState,
|
||
scope: stateClaims.ssoScope,
|
||
codeChallenge: stateClaims.ssoCodeChallenge,
|
||
codeChallengeMethod: 'S256',
|
||
}, { ...options, store });
|
||
|
||
const code = crypto.randomBytes(32).toString('base64url');
|
||
const expiresAt = Date.now() + AUTHORIZATION_CODE_TTL_SECONDS * 1000;
|
||
await store.insertAuthorizationCode({
|
||
codeHash: hashValue(code),
|
||
clientId: validated.client.clientId,
|
||
userId: Number(userId),
|
||
redirectUri: validated.redirectUri,
|
||
scope: validated.scopes.join(' '),
|
||
codeChallenge: validated.codeChallenge,
|
||
expiresAt,
|
||
});
|
||
void store.cleanupExpired?.().catch(() => {});
|
||
|
||
const redirect = new URL(validated.redirectUri);
|
||
redirect.searchParams.set('code', code);
|
||
redirect.searchParams.set('state', validated.clientState);
|
||
await auditSafely(store, {
|
||
eventType: 'authorization_code_issued',
|
||
clientId: validated.client.clientId,
|
||
userId,
|
||
...requestContext,
|
||
});
|
||
|
||
return {
|
||
redirectUrl: redirect.toString(),
|
||
expiresIn: AUTHORIZATION_CODE_TTL_SECONDS,
|
||
};
|
||
}
|
||
|
||
async function authenticateClient(credentials = {}, options = {}) {
|
||
const store = options.store || defaultStore;
|
||
const client = await requireActiveClient(credentials.clientId, { ...options, store });
|
||
if (client.clientType === 'public') return client;
|
||
|
||
const secret = String(credentials.clientSecret || '');
|
||
if (!secret || !timingSafeEqualText(hashValue(secret), client.clientSecretHash)) {
|
||
throw new ThirdPartySsoError('client_secret 无效', 401, 'invalid_client');
|
||
}
|
||
return client;
|
||
}
|
||
|
||
async function exchangeAuthorizationCode(input = {}, credentials = {}, options = {}) {
|
||
if (String(input.grantType || input.grant_type || '') !== 'authorization_code') {
|
||
throw new ThirdPartySsoError('仅支持 authorization_code', 400, 'unsupported_grant_type');
|
||
}
|
||
|
||
const store = options.store || defaultStore;
|
||
const client = await authenticateClient(credentials, { ...options, store });
|
||
const rawCode = String(input.code || '').trim();
|
||
if (!/^[A-Za-z0-9_-]{40,100}$/u.test(rawCode)) {
|
||
throw new ThirdPartySsoError('授权码无效', 400, 'invalid_grant');
|
||
}
|
||
|
||
const record = await store.findAuthorizationCode(hashValue(rawCode), client.clientId);
|
||
if (!record || record.consumedAt || new Date(record.expiresAt).getTime() <= Date.now()) {
|
||
throw new ThirdPartySsoError('授权码无效、已使用或已过期', 400, 'invalid_grant');
|
||
}
|
||
|
||
const redirectUri = normalizeRedirectUri(input.redirectUri || input.redirect_uri);
|
||
if (redirectUri !== normalizeRedirectUri(record.redirectUri)) {
|
||
throw new ThirdPartySsoError('redirect_uri 与授权请求不一致', 400, 'invalid_grant');
|
||
}
|
||
|
||
const verifier = normalizeCodeVerifier(input.codeVerifier || input.code_verifier);
|
||
if (!timingSafeEqualText(buildCodeChallenge(verifier), record.codeChallenge)) {
|
||
throw new ThirdPartySsoError('PKCE 校验失败', 400, 'invalid_grant');
|
||
}
|
||
|
||
const consumed = await store.consumeAuthorizationCode(hashValue(rawCode), client.clientId);
|
||
if (Number(consumed) !== 1) {
|
||
throw new ThirdPartySsoError('授权码已被使用', 400, 'invalid_grant');
|
||
}
|
||
|
||
const resolveUser = options.resolveUser || getResolvedUserWithPageAccess;
|
||
const user = await resolveUser(record.userId);
|
||
if (!user || (user.status && user.status !== 'active')) {
|
||
throw new ThirdPartySsoError('用户不存在或已停用', 403, 'access_denied');
|
||
}
|
||
|
||
const scopes = normalizeScopes(record.scope);
|
||
const jti = crypto.randomUUID();
|
||
const accessToken = jwt.sign(
|
||
{
|
||
sub: String(user.id),
|
||
tokenType: TOKEN_TYPE,
|
||
clientId: client.clientId,
|
||
scope: scopes.join(' '),
|
||
},
|
||
getSigningSecret(),
|
||
{
|
||
expiresIn: ACCESS_TOKEN_TTL_SECONDS,
|
||
issuer: TOKEN_ISSUER,
|
||
audience: client.clientId,
|
||
jwtid: jti,
|
||
}
|
||
);
|
||
await store.touchClient(client.clientId);
|
||
await auditSafely(store, {
|
||
eventType: 'access_token_issued',
|
||
clientId: client.clientId,
|
||
userId: user.id,
|
||
...(options.requestContext || {}),
|
||
});
|
||
|
||
return {
|
||
access_token: accessToken,
|
||
token_type: 'Bearer',
|
||
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
||
scope: scopes.join(' '),
|
||
};
|
||
}
|
||
|
||
async function verifyThirdPartyAccessToken(token, options = {}) {
|
||
const rawToken = String(token || '').trim();
|
||
const decoded = jwt.decode(rawToken);
|
||
const clientId = String(decoded?.clientId || decoded?.aud || '').trim();
|
||
if (!clientId) {
|
||
throw new ThirdPartySsoError('访问令牌无效', 401, 'invalid_token');
|
||
}
|
||
|
||
const store = options.store || defaultStore;
|
||
const client = await requireActiveClient(clientId, { ...options, store });
|
||
let claims;
|
||
try {
|
||
claims = jwt.verify(rawToken, getSigningSecret(), {
|
||
issuer: TOKEN_ISSUER,
|
||
audience: client.clientId,
|
||
});
|
||
} catch (_error) {
|
||
throw new ThirdPartySsoError('访问令牌无效或已过期', 401, 'invalid_token');
|
||
}
|
||
|
||
if (claims.tokenType !== TOKEN_TYPE || !claims.jti || await store.isTokenRevoked(claims.jti)) {
|
||
throw new ThirdPartySsoError('访问令牌无效或已撤销', 401, 'invalid_token');
|
||
}
|
||
|
||
const resolveUser = options.resolveUser || getResolvedUserWithPageAccess;
|
||
const user = await resolveUser(claims.sub);
|
||
if (!user || (user.status && user.status !== 'active')) {
|
||
throw new ThirdPartySsoError('用户不存在或已停用', 401, 'invalid_token');
|
||
}
|
||
|
||
return {
|
||
claims,
|
||
client,
|
||
user,
|
||
scopes: normalizeScopes(claims.scope),
|
||
};
|
||
}
|
||
|
||
function buildUserInfo(verified) {
|
||
const { user, scopes } = verified;
|
||
const scopeSet = new Set(scopes);
|
||
const result = { sub: String(user.id) };
|
||
if (scopeSet.has('profile')) {
|
||
Object.assign(result, {
|
||
username: user.username,
|
||
name: user.name,
|
||
picture: user.picture || user.avatar_url || '',
|
||
auth_source: user.auth_source || user.authSource || 'dingtalk',
|
||
});
|
||
}
|
||
if (scopeSet.has('email')) result.email = user.email || '';
|
||
if (scopeSet.has('department')) {
|
||
result.department = {
|
||
id: user.departmentId || user.department_id || null,
|
||
name: user.departmentName || '',
|
||
};
|
||
}
|
||
if (scopeSet.has('permissions')) {
|
||
result.permissions = {
|
||
role: user.role || 'user',
|
||
allowed_page_keys: Array.isArray(user.allowedPageKeys) ? user.allowedPageKeys : [],
|
||
};
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function revokeAccessToken(token, credentials = {}, options = {}) {
|
||
const rawToken = String(token || '').trim();
|
||
const decoded = jwt.decode(rawToken);
|
||
if (!decoded?.clientId || !decoded?.jti) return { revoked: true };
|
||
|
||
const store = options.store || defaultStore;
|
||
const client = await authenticateClient(credentials, { ...options, store });
|
||
if (decoded.clientId !== client.clientId) return { revoked: true };
|
||
|
||
let claims;
|
||
try {
|
||
claims = jwt.verify(rawToken, getSigningSecret(), {
|
||
issuer: TOKEN_ISSUER,
|
||
audience: client.clientId,
|
||
ignoreExpiration: true,
|
||
});
|
||
} catch (_error) {
|
||
return { revoked: true };
|
||
}
|
||
|
||
if (claims.tokenType === TOKEN_TYPE && claims.exp > Math.floor(Date.now() / 1000)) {
|
||
await store.revokeToken({
|
||
jti: claims.jti,
|
||
clientId: client.clientId,
|
||
userId: claims.sub,
|
||
expiresAt: claims.exp * 1000,
|
||
});
|
||
await auditSafely(store, {
|
||
eventType: 'access_token_revoked',
|
||
clientId: client.clientId,
|
||
userId: claims.sub,
|
||
...(options.requestContext || {}),
|
||
});
|
||
}
|
||
return { revoked: true };
|
||
}
|
||
|
||
function publicClient(client) {
|
||
return {
|
||
clientId: client.clientId,
|
||
clientName: client.clientName,
|
||
clientType: client.clientType,
|
||
redirectUris: client.redirectUris,
|
||
allowedScopes: client.allowedScopes,
|
||
status: client.status,
|
||
lastUsedAt: client.lastUsedAt,
|
||
createdAt: client.createdAt,
|
||
updatedAt: client.updatedAt,
|
||
};
|
||
}
|
||
|
||
async function listSsoClients(options = {}) {
|
||
const store = options.store || defaultStore;
|
||
return (await store.listClients()).map(publicClient);
|
||
}
|
||
|
||
async function createSsoClient(input = {}, actor = {}, options = {}) {
|
||
const clientName = String(input.clientName || input.name || '').trim().slice(0, 120);
|
||
if (!clientName) {
|
||
throw new ThirdPartySsoError('请填写应用名称', 400, 'invalid_request');
|
||
}
|
||
const clientType = input.clientType === 'public' ? 'public' : 'confidential';
|
||
const redirectUris = normalizeRedirectUris(
|
||
input.redirectUris,
|
||
options.redirectUriNormalizer || normalizeRedirectUri
|
||
);
|
||
const allowedScopes = normalizeScopes(input.allowedScopes);
|
||
const clientId = `sso_${crypto.randomBytes(18).toString('base64url')}`;
|
||
const clientSecret = clientType === 'confidential'
|
||
? crypto.randomBytes(32).toString('base64url')
|
||
: '';
|
||
const store = options.store || defaultStore;
|
||
const client = await store.createClient({
|
||
clientId,
|
||
clientName,
|
||
clientType,
|
||
clientSecretHash: clientSecret ? hashValue(clientSecret) : '',
|
||
redirectUris,
|
||
allowedScopes,
|
||
createdBy: actor.id || actor.sub || null,
|
||
});
|
||
await auditSafely(store, {
|
||
eventType: 'client_created',
|
||
clientId,
|
||
userId: actor.id || actor.sub || null,
|
||
});
|
||
return { ...publicClient(client), clientSecret: clientSecret || undefined };
|
||
}
|
||
|
||
async function updateSsoClient(clientId, input = {}, actor = {}, options = {}) {
|
||
const store = options.store || defaultStore;
|
||
const existing = await requireActiveClient(clientId, { ...options, store }).catch(async (error) => {
|
||
const disabled = await store.findClient(normalizeClientId(clientId));
|
||
if (disabled) return disabled;
|
||
throw error;
|
||
});
|
||
const changes = {};
|
||
if (input.clientName !== undefined || input.name !== undefined) {
|
||
changes.clientName = String(input.clientName || input.name || '').trim().slice(0, 120);
|
||
if (!changes.clientName) throw new ThirdPartySsoError('应用名称不能为空');
|
||
}
|
||
if (input.redirectUris !== undefined) {
|
||
changes.redirectUris = normalizeRedirectUris(
|
||
input.redirectUris,
|
||
options.redirectUriNormalizer || normalizeRedirectUri
|
||
);
|
||
}
|
||
if (input.allowedScopes !== undefined) changes.allowedScopes = normalizeScopes(input.allowedScopes);
|
||
if (input.status !== undefined) {
|
||
if (!['active', 'disabled'].includes(input.status)) {
|
||
throw new ThirdPartySsoError('应用状态无效');
|
||
}
|
||
changes.status = input.status;
|
||
}
|
||
const updated = await store.updateClient(existing.clientId, changes);
|
||
await auditSafely(store, {
|
||
eventType: 'client_updated',
|
||
clientId: existing.clientId,
|
||
userId: actor.id || actor.sub || null,
|
||
detail: { fields: Object.keys(changes) },
|
||
});
|
||
return publicClient(updated);
|
||
}
|
||
|
||
async function rotateSsoClientSecret(clientId, actor = {}, options = {}) {
|
||
const store = options.store || defaultStore;
|
||
const client = await requireActiveClient(clientId, { ...options, store });
|
||
if (client.clientType !== 'confidential') {
|
||
throw new ThirdPartySsoError('公开客户端使用 PKCE,不包含 client_secret');
|
||
}
|
||
const clientSecret = crypto.randomBytes(32).toString('base64url');
|
||
const updated = await store.updateClient(client.clientId, {
|
||
clientSecretHash: hashValue(clientSecret),
|
||
});
|
||
await auditSafely(store, {
|
||
eventType: 'client_secret_rotated',
|
||
clientId: client.clientId,
|
||
userId: actor.id || actor.sub || null,
|
||
});
|
||
return { ...publicClient(updated), clientSecret };
|
||
}
|
||
|
||
async function ensureThirdPartySsoSchema() {
|
||
await database.local.query(
|
||
`CREATE TABLE IF NOT EXISTS third_party_sso_clients (
|
||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||
client_id VARCHAR(80) NOT NULL,
|
||
client_name VARCHAR(120) NOT NULL,
|
||
client_type ENUM('confidential', 'public') NOT NULL DEFAULT 'confidential',
|
||
client_secret_hash CHAR(64) NULL,
|
||
redirect_uris TEXT NOT NULL,
|
||
allowed_scopes VARCHAR(500) NOT NULL DEFAULT '["profile"]',
|
||
status ENUM('active', 'disabled') NOT NULL DEFAULT 'active',
|
||
created_by BIGINT NULL,
|
||
last_used_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uk_third_party_sso_clients_client_id (client_id),
|
||
KEY idx_third_party_sso_clients_status (status)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认证应用'`
|
||
);
|
||
await database.local.query(
|
||
`CREATE TABLE IF NOT EXISTS third_party_sso_authorization_codes (
|
||
code_hash CHAR(64) PRIMARY KEY,
|
||
client_id VARCHAR(80) NOT NULL,
|
||
user_id BIGINT NOT NULL,
|
||
redirect_uri VARCHAR(1024) NOT NULL,
|
||
scope VARCHAR(500) NOT NULL,
|
||
code_challenge VARCHAR(128) NOT NULL,
|
||
expires_at DATETIME NOT NULL,
|
||
consumed_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
KEY idx_third_party_sso_codes_client (client_id),
|
||
KEY idx_third_party_sso_codes_expires (expires_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认证一次性授权码'`
|
||
);
|
||
await database.local.query(
|
||
`CREATE TABLE IF NOT EXISTS third_party_sso_revoked_tokens (
|
||
jti_hash CHAR(64) PRIMARY KEY,
|
||
client_id VARCHAR(80) NOT NULL,
|
||
user_id BIGINT NOT NULL,
|
||
expires_at DATETIME NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
KEY idx_third_party_sso_revoked_expires (expires_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认撤销令牌'`
|
||
);
|
||
await database.local.query(
|
||
`CREATE TABLE IF NOT EXISTS third_party_sso_audit_logs (
|
||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||
event_type VARCHAR(80) NOT NULL,
|
||
client_id VARCHAR(80) NULL,
|
||
user_id BIGINT NULL,
|
||
result VARCHAR(30) NOT NULL DEFAULT 'success',
|
||
ip_address VARCHAR(64) NULL,
|
||
user_agent VARCHAR(500) NULL,
|
||
detail TEXT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
KEY idx_third_party_sso_audit_client_time (client_id, created_at),
|
||
KEY idx_third_party_sso_audit_user_time (user_id, created_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认审计日志'`
|
||
);
|
||
}
|
||
|
||
module.exports = {
|
||
ACCESS_TOKEN_TTL_SECONDS,
|
||
AUTHORIZATION_CODE_TTL_SECONDS,
|
||
SUPPORTED_SCOPES,
|
||
THIRD_PARTY_FLOW,
|
||
TOKEN_ISSUER,
|
||
TOKEN_TYPE,
|
||
ThirdPartySsoError,
|
||
authenticateClient,
|
||
buildCodeChallenge,
|
||
buildUserInfo,
|
||
completeThirdPartyAuthorization,
|
||
createSsoClient,
|
||
ensureThirdPartySsoSchema,
|
||
exchangeAuthorizationCode,
|
||
getThirdPartySsoCallbackBaseUrl,
|
||
hashValue,
|
||
listSsoClients,
|
||
normalizeRedirectUri,
|
||
revokeAccessToken,
|
||
rotateSsoClientSecret,
|
||
updateSsoClient,
|
||
validateAuthorizationRequest,
|
||
verifyThirdPartyAccessToken,
|
||
};
|