Files
rainblogweb/routes/oidc.js
T

141 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// RainID OIDC 授权码 + PKCE 接入(挂 /api/auth/oidc
// 流程:GET /login 发起 → RainID 登录/同意 → GET /callback 换 token 建影子账号
// → 一次性 ticket → 前端 POST /exchange 换本地 JWT(避免长 token 进 URL
// → GET /logout 登出联动(end_session
// 安全:state 一次性 + PKCE S256SDK 自动验 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:回调 → 前端换 JWT30 秒
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/loginauth.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_sessionpost_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;