141 lines
5.8 KiB
JavaScript
141 lines
5.8 KiB
JavaScript
// RainID OIDC 授权码 + PKCE 接入(挂 /api/auth/oidc)
|
||
// 流程:GET /login 发起 → RainID 登录/同意 → GET /callback 换 token 建影子账号
|
||
// → 一次性 ticket → 前端 POST /exchange 换本地 JWT(避免长 token 进 URL)
|
||
// → GET /logout 登出联动(end_session)
|
||
// 安全:state 一次性 + PKCE S256(SDK 自动验 id_token iss/aud/exp/签名);ticket 30s 一次性。
|
||
const express = require('express');
|
||
const crypto = require('crypto');
|
||
const jwt = require('jsonwebtoken');
|
||
const openidClient = require('openid-client'); // v6 函数式 API
|
||
const { SECRET } = require('../middleware/auth');
|
||
const {
|
||
getOidcSettings,
|
||
getOidcConfig,
|
||
siteBase,
|
||
findOrCreateRainidUser,
|
||
} = require('../lib/rainid');
|
||
|
||
const router = express.Router();
|
||
|
||
// ---- OIDC 流程状态:内存 Map + 短 TTL(模式同 routes/captcha.js 的 captchaStore)----
|
||
const OIDC_STATE_TTL = 10 * 60 * 1000; // state:发起登录 → 回调,10 分钟
|
||
const OIDC_TICKET_TTL = 30 * 1000; // ticket:回调 → 前端换 JWT,30 秒
|
||
const oidcStates = new Map(); // state -> { verifier, createdAt }
|
||
const oidcTickets = new Map(); // ticket -> { jwt, username, role, email, email_verified, createdAt }
|
||
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [k, v] of oidcStates) if (now - v.createdAt > OIDC_STATE_TTL) oidcStates.delete(k);
|
||
for (const [k, v] of oidcTickets) if (now - v.createdAt > OIDC_TICKET_TTL) oidcTickets.delete(k);
|
||
}, 60000);
|
||
|
||
const REDIRECT_PATH = '/api/auth/oidc/callback';
|
||
const LOGOUT_REDIRECT_PATH = '/login.html'; // RainID 登出回跳白名单需登记该地址
|
||
|
||
function frontLoginUrl(req, qs) {
|
||
return siteBase(req) + '/login.html' + (qs || '');
|
||
}
|
||
|
||
// ① 发起登录:PKCE + state → 302 RainID authorize
|
||
router.get('/login', async (req, res) => {
|
||
const s = getOidcSettings();
|
||
if (!s.enabled) return res.status(400).json({ error: '未启用 RainID 登录' });
|
||
try {
|
||
const config = await getOidcConfig();
|
||
const codeVerifier = openidClient.randomPKCECodeVerifier();
|
||
const codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier);
|
||
const state = openidClient.randomState();
|
||
oidcStates.set(state, { verifier: codeVerifier, createdAt: Date.now() });
|
||
const url = openidClient.buildAuthorizationUrl(config, {
|
||
redirect_uri: siteBase(req) + REDIRECT_PATH,
|
||
scope: 'openid profile email',
|
||
code_challenge: codeChallenge,
|
||
code_challenge_method: 'S256',
|
||
state,
|
||
});
|
||
res.redirect(url.href);
|
||
} catch (err) {
|
||
console.error('[RainID] login redirect error:', err.message);
|
||
res.status(502).json({ error: 'RainID 服务暂不可用' });
|
||
}
|
||
});
|
||
|
||
// ② 回调:state 校验 → 换 token → userinfo → 影子账号 → 发 JWT → 一次性 ticket → 302 前端
|
||
router.get('/callback', async (req, res) => {
|
||
const state = req.query.state;
|
||
const stored = state && oidcStates.get(state);
|
||
if (!stored) {
|
||
return res.status(400).send('登录状态已失效,请重新使用 RainID 登录');
|
||
}
|
||
oidcStates.delete(state); // state 一次性
|
||
let config;
|
||
try { config = await getOidcConfig(); }
|
||
catch { return res.status(500).send('RainID 客户端未配置'); }
|
||
|
||
const base = siteBase(req);
|
||
const currentUrl = new URL(req.originalUrl, base);
|
||
try {
|
||
// openid-client 自动验 id_token:签名(RS256/JWKS) + iss(discovery issuer) + aud(client_id) + exp
|
||
const tokens = await openidClient.authorizationCodeGrant(config, currentUrl, {
|
||
pkceCodeVerifier: stored.verifier,
|
||
expectedState: state,
|
||
idTokenExpected: true,
|
||
});
|
||
const claims = tokens.claims();
|
||
const sub = claims && claims.sub;
|
||
if (!sub) throw new Error('id_token 缺少 sub');
|
||
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub);
|
||
const user = findOrCreateRainidUser(sub, userinfo);
|
||
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, SECRET, { expiresIn: '7d' });
|
||
const ticket = crypto.randomBytes(16).toString('hex');
|
||
oidcTickets.set(ticket, {
|
||
jwt: token,
|
||
username: user.username,
|
||
role: user.role,
|
||
email: user.email,
|
||
email_verified: user.email_verified,
|
||
createdAt: Date.now(),
|
||
});
|
||
res.redirect(frontLoginUrl(req, '?oidc_ticket=' + encodeURIComponent(ticket)));
|
||
} catch (err) {
|
||
// error 参数(access_denied / consent_required 等)或换 token 失败 → 回前端登录页带错误码
|
||
const code = err && err.error;
|
||
console.error('[RainID] callback error:', code || err.message);
|
||
res.redirect(frontLoginUrl(req, '?oidc_error=' + encodeURIComponent(code || 'server_error')));
|
||
}
|
||
});
|
||
|
||
// ③ 一次性 ticket 换本地 JWT(响应结构同 /api/auth/login,auth.js:91)
|
||
router.post('/exchange', (req, res) => {
|
||
const ticket = req.body && req.body.ticket;
|
||
if (!ticket) return res.status(400).json({ error: '缺少 ticket' });
|
||
const rec = oidcTickets.get(ticket);
|
||
if (!rec) return res.status(401).json({ error: '登录已过期,请重新使用 RainID 登录' });
|
||
oidcTickets.delete(ticket); // 一次性
|
||
res.json({
|
||
token: rec.jwt,
|
||
username: rec.username,
|
||
role: rec.role,
|
||
email: rec.email,
|
||
email_verified: rec.email_verified,
|
||
});
|
||
});
|
||
|
||
// ④ 登出联动:302 RainID end_session(post_logout_redirect_uri 需先在 RainID Admin 登记)
|
||
router.get('/logout', async (req, res) => {
|
||
const s = getOidcSettings();
|
||
if (!s.enabled) return res.redirect('/');
|
||
try {
|
||
const config = await getOidcConfig();
|
||
const url = openidClient.buildEndSessionUrl(config, {
|
||
post_logout_redirect_uri: siteBase(req) + LOGOUT_REDIRECT_PATH,
|
||
});
|
||
res.redirect(url.href);
|
||
} catch (err) {
|
||
console.error('[RainID] logout error:', err.message);
|
||
res.redirect('/');
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|