feat: RainID 单点登录接入(ROPC 密码登录 + 授权码 PKCE + 影子账号 + 注册跳转 + 登出联动)
This commit is contained in:
@@ -1,14 +1,28 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { required as captchaRequired } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||
|
||||
/** RainID OIDC 回跳错误码 → 用户可读文案 */
|
||||
const OIDC_ERROR_TEXT = {
|
||||
access_denied: '已在 RainID 拒绝授权,可重新登录',
|
||||
consent_required: '请重新完整走 RainID 登录',
|
||||
login_required: '请重新完整走 RainID 登录',
|
||||
invalid_grant: '登录状态已失效,请重新登录',
|
||||
rate_limited: '请求过于频繁,请稍后再试',
|
||||
invalid_scope: '配置错误,请联系管理员',
|
||||
invalid_request: '配置错误,请联系管理员',
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录页(迁移自 login.html):
|
||||
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
||||
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
||||
* RainID 单点登录:rainid_enabled 时显示「使用 RainID 登录」按钮;回跳(?oidc_ticket / ?oidc_error)
|
||||
* 在此页面收尾——一次性过渡码换 token / 错误码映射提示。ROPC 表单逻辑不变(后端已转发)。
|
||||
*/
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
@@ -21,19 +35,69 @@ export default function Login() {
|
||||
const [capRequired, setCapRequired] = useState(false);
|
||||
const [capDone, setCapDone] = useState(false);
|
||||
const [capResult, setCapResult] = useState(null);
|
||||
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||
const [oidcBusy, setOidcBusy] = useState(false);
|
||||
|
||||
/** 清理地址栏的 oidc 过渡参数(替换为纯路径,避免残留/刷新重复兑换) */
|
||||
const clearOidcParams = () => {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
};
|
||||
|
||||
/** RainID 回跳收尾:一次性过渡码换 token → 复用本地登录成功逻辑 */
|
||||
const handleOidcTicket = async (ticket) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await authApi.oidcExchange(ticket);
|
||||
setToken(data.token);
|
||||
notifyAuthChange();
|
||||
clearOidcParams();
|
||||
const from = location.state && location.state.from;
|
||||
navigate(from || '/');
|
||||
} catch (e) {
|
||||
clearOidcParams();
|
||||
setError(e.message || 'RainID 登录失败,请重试');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 1) RainID 回跳优先处理(一次性码 30s 有效,须先兑换)
|
||||
// 注意:OIDC 走整页跳转,此 effect 仅在页面挂载时执行一次;
|
||||
// 兑换后 navigate 触发 location 变化时不再重跑(避免覆盖 from 目标)。
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ticket = params.get('oidc_ticket');
|
||||
const oidcError = params.get('oidc_error');
|
||||
if (ticket) { handleOidcTicket(ticket); return; }
|
||||
if (oidcError) {
|
||||
setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败,请重试');
|
||||
clearOidcParams();
|
||||
return;
|
||||
}
|
||||
// 2) 已登录直接回首页
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
// 3) 公开设置:rainid_enabled → 显示 RainID 按钮
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => { if (s.rainid_enabled === '1') setRainidEnabled(true); })
|
||||
.catch(() => {});
|
||||
// 4) 人机验证需求
|
||||
captchaRequired('login')
|
||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||
.catch(() => {});
|
||||
}, [navigate]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const doCaptcha = async () => {
|
||||
const result = await showCaptcha('login');
|
||||
if (result) { setCapResult(result); setCapDone(true); }
|
||||
};
|
||||
|
||||
/** RainID 整页跳转(loading 防重复点击) */
|
||||
const handleRainId = () => {
|
||||
if (oidcBusy) return;
|
||||
setOidcBusy(true);
|
||||
authApi.oidcLoginRedirect();
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
|
||||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||||
@@ -46,6 +110,7 @@ export default function Login() {
|
||||
const from = location.state && location.state.from;
|
||||
navigate(from || '/');
|
||||
} catch (e) {
|
||||
// 401 文案透传(含 RainID 已开启时的 2FA 提示:"该账号已开启二次验证,请使用 RainID 登录")
|
||||
setError(e.message || '登录失败');
|
||||
setBusy(false);
|
||||
// 验证码 proof 为一次性消费:无论密码错还是 proof 已被使用,登录失败后均作废,
|
||||
@@ -119,6 +184,18 @@ export default function Login() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{rainidEnabled && (
|
||||
<button
|
||||
className="btn btn-outline w-full"
|
||||
onClick={handleRainId}
|
||||
disabled={busy || oidcBusy}
|
||||
style={{ display: 'inline-flex', marginBottom: 8, justifyContent: 'center', height: 44, gap: 8, alignItems: 'center' }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>fingerprint</span>
|
||||
<span>{oidcBusy ? '正在跳转 RainID…' : '使用 RainID 登录'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="btn btn-filled w-full" onClick={handleLogin} disabled={busy}>
|
||||
{busy ? '登录中...' : '登录'}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user