From 04f835408d6adc3f077916c5a6a575494436fc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=8D=9A?= Date: Tue, 18 Aug 2026 20:22:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0OIDC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG | 6 + README.md | 22 ++ package.json | 1 + pnpm-lock.yaml | 8 + src/app/admin/page.tsx | 9 +- src/app/api/login/route.ts | 103 +---- src/app/api/logout/route.ts | 27 +- src/app/api/oidc/callback/route.ts | 146 +++++++ src/app/api/oidc/login/route.ts | 85 ++++ src/app/api/server-config/route.ts | 2 + src/app/layout.tsx | 2 + src/app/login/page.tsx | 151 ++++++-- src/components/UserMenu.tsx | 5 +- src/lib/admin.types.ts | 2 + src/lib/auth.ts | 106 ++++- src/lib/changelog.ts | 13 + src/lib/config.ts | 2 + src/lib/oidc.test.ts | 368 ++++++++++++++++++ src/lib/oidc.ts | 604 +++++++++++++++++++++++++++++ src/lib/version.ts | 2 +- src/middleware.ts | 3 +- 21 files changed, 1516 insertions(+), 151 deletions(-) create mode 100644 src/app/api/oidc/callback/route.ts create mode 100644 src/app/api/oidc/login/route.ts create mode 100644 src/lib/oidc.test.ts create mode 100644 src/lib/oidc.ts diff --git a/CHANGELOG b/CHANGELOG index 7d4ef9a52b..261b53e8a3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +## [100.1.4] - 2026-08-18 + +### Added + +- 支持标准 OIDC 登录,并映射到现有用户系统(收藏、播放记录、角色权限保持不变) + ## [100.1.3] - 2026-05-28 ### Fixed diff --git a/README.md b/README.md index ca35b810b3..cef4f9ddc8 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,28 @@ dockge/komodo 等 docker compose UI 也有自动更新功能 | NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) | | NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false | | NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true | +| OIDC_ISSUER | OIDC 发行方地址(配置后自动启用 OIDC) | `https://auth.example.com` 或完整 discovery URL | 空(不启用) | +| OIDC_CLIENT_ID | OIDC 客户端 ID | 字符串 | 空 | +| OIDC_CLIENT_SECRET | OIDC 客户端密钥(机密客户端需要) | 字符串 | 空 | +| OIDC_REDIRECT_URI | OIDC 回调地址 | 完整 URL | `{SITE_BASE}/api/oidc/callback` | +| OIDC_SCOPES | OIDC 授权范围 | 空格分隔 | `openid profile email` | +| OIDC_USERNAME_CLAIM | 映射到 LunaTV 用户名的 claim | `preferred_username` / `email` / `sub` 等 | `preferred_username` | +| OIDC_AUTO_REGISTER | 首次 OIDC 登录是否自动创建本地用户 | true/false | true | +| OIDC_DISABLE_PASSWORD | 启用 OIDC 后是否隐藏账号密码登录 | true/false | false | +| OIDC_BUTTON_TEXT | 登录页 OIDC 按钮文案 | 任意字符串 | OIDC 登录 | +| OIDC_ENABLED | 强制开关(一般无需设置) | true/false | 配置了 ISSUER + CLIENT_ID 即为 true | +| OIDC_CLIENT_AUTH | 令牌端点客户端认证方式 | basic / post / none | 按 IdP 发现文档自动选择,缺省 post | + +OIDC 说明: + +- **仅支持** `redis` / `kvrocks` / `upstash` 多用户存储,不支持 `localstorage` 单密码模式 +- 请在身份提供方登记回调地址:`https://你的站点/api/oidc/callback`,并设置相同的 `SITE_BASE` 或 `OIDC_REDIRECT_URI`。反代 / HTTPS / 非 localhost 访问时必须显式配置,否则换票会因回调地址不一致失败 +- 机密客户端必须配置 `OIDC_CLIENT_SECRET`。若 IdP 只接受 Basic 认证,可设 `OIDC_CLIENT_AUTH=basic` +- OIDC 用户会进入现有用户系统:可在管理面板封禁、提权、分配用户组;收藏和播放记录按用户名同步 +- 用户名优先取 `OIDC_USERNAME_CLAIM`,缺省回退 `preferred_username` → `email` → `name` → `sub` +- 若解析出的用户名等于 `USERNAME`,将以站长身份登录 +- 已存在的同名本地用户会自动绑定 OIDC `sub`,之后即使 IdP 用户名变更也按 `sub` 识别 +- 建议把用户范围限制在可信 IdP 内;若只允许预创建账号登录,设置 `OIDC_AUTO_REGISTER=false` NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: diff --git a/package.json b/package.json index ee4f82f261..7b22356d54 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "framer-motion": "^12.18.1", "he": "^1.2.0", "hls.js": "^1.6.10", + "jose": "5.10.0", "lucide-react": "^0.438.0", "media-icons": "^1.1.5", "next": "^14.2.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b679f62d46..4dc11ac375 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: hls.js: specifier: ^1.6.10 version: 1.6.10 + jose: + specifier: 5.10.0 + version: 5.10.0 lucide-react: specifier: ^0.438.0 version: 0.438.0(react@18.3.1) @@ -3553,6 +3556,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + js-string-escape@1.0.1: resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==} engines: {node: '>= 0.8'} @@ -9486,6 +9492,8 @@ snapshots: jiti@1.21.7: {} + jose@5.10.0: {} + js-string-escape@1.0.1: {} js-tokens@4.0.0: {} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 830fffee7d..9f8ed38488 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1139,7 +1139,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { )} - {user.username} +
+ {user.username} + {user.from === 'oidc' && ( + + OIDC + + )} +
{ - const encoder = new TextEncoder(); - const keyData = encoder.encode(secret); - const messageData = encoder.encode(data); - - // 导入密钥 - const key = await crypto.subtle.importKey( - 'raw', - keyData, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); - - // 生成签名 - const signature = await crypto.subtle.sign('HMAC', key, messageData); - - // 转换为十六进制字符串 - return Array.from(new Uint8Array(signature)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} - -// 生成认证Cookie(带签名) -async function generateAuthCookie( - username?: string, - password?: string, - role?: 'owner' | 'admin' | 'user', - includePassword = false -): Promise { - const authData: any = { role: role || 'user' }; - - // 只在需要时包含 password - if (includePassword && password) { - authData.password = password; - } - - if (username && process.env.PASSWORD) { - authData.username = username; - // 使用密码作为密钥对用户名进行签名 - const signature = await generateSignature(username, process.env.PASSWORD); - authData.signature = signature; - authData.timestamp = Date.now(); // 添加时间戳防重放攻击 - } - - return encodeURIComponent(JSON.stringify(authData)); -} - export async function POST(req: NextRequest) { try { // 本地 / localStorage 模式——仅校验固定密码 @@ -78,13 +27,7 @@ export async function POST(req: NextRequest) { const response = NextResponse.json({ ok: true }); // 清除可能存在的认证cookie - response.cookies.set('auth', '', { - path: '/', - expires: new Date(0), - sameSite: 'lax', // 改为 lax 以支持 PWA - httpOnly: false, // PWA 需要客户端可访问 - secure: false, // 根据协议自动设置 - }); + clearAuthCookie(response); return response; } @@ -107,18 +50,10 @@ export async function POST(req: NextRequest) { undefined, password, 'user', - true + true, + 'password' ); // localstorage 模式包含 password - const expires = new Date(); - expires.setDate(expires.getDate() + 7); // 7天过期 - - response.cookies.set('auth', cookieValue, { - path: '/', - expires, - sameSite: 'lax', // 改为 lax 以支持 PWA - httpOnly: false, // PWA 需要客户端可访问 - secure: false, // 根据协议自动设置 - }); + applyAuthCookie(response, cookieValue); return response; } @@ -144,18 +79,10 @@ export async function POST(req: NextRequest) { username, password, 'owner', - false + false, + 'password' ); // 数据库模式不包含 password - const expires = new Date(); - expires.setDate(expires.getDate() + 7); // 7天过期 - - response.cookies.set('auth', cookieValue, { - path: '/', - expires, - sameSite: 'lax', // 改为 lax 以支持 PWA - httpOnly: false, // PWA 需要客户端可访问 - secure: false, // 根据协议自动设置 - }); + applyAuthCookie(response, cookieValue); return response; } else if (username === process.env.USERNAME) { @@ -184,18 +111,10 @@ export async function POST(req: NextRequest) { username, password, user?.role || 'user', - false + false, + 'password' ); // 数据库模式不包含 password - const expires = new Date(); - expires.setDate(expires.getDate() + 7); // 7天过期 - - response.cookies.set('auth', cookieValue, { - path: '/', - expires, - sameSite: 'lax', // 改为 lax 以支持 PWA - httpOnly: false, // PWA 需要客户端可访问 - secure: false, // 根据协议自动设置 - }); + applyAuthCookie(response, cookieValue); return response; } catch (err) { diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts index 8bfa015f08..ecba796ce6 100644 --- a/src/app/api/logout/route.ts +++ b/src/app/api/logout/route.ts @@ -1,18 +1,33 @@ import { NextResponse } from 'next/server'; +import { clearAuthCookie } from '@/lib/auth'; +import { + OIDC_CALLBACK_URI_COOKIE, + OIDC_FLOW_COOKIE_BASE, + OIDC_NONCE_COOKIE, + OIDC_REDIRECT_COOKIE, + OIDC_STATE_COOKIE, + OIDC_VERIFIER_COOKIE, +} from '@/lib/oidc'; + export const runtime = 'nodejs'; export async function POST() { const response = NextResponse.json({ ok: true }); // 清除认证cookie - response.cookies.set('auth', '', { - path: '/', + clearAuthCookie(response); + + const expired = { + ...OIDC_FLOW_COOKIE_BASE, + maxAge: 0, expires: new Date(0), - sameSite: 'lax', // 改为 lax 以支持 PWA - httpOnly: false, // PWA 需要客户端可访问 - secure: false, // 根据协议自动设置 - }); + }; + response.cookies.set(OIDC_STATE_COOKIE, '', expired); + response.cookies.set(OIDC_NONCE_COOKIE, '', expired); + response.cookies.set(OIDC_VERIFIER_COOKIE, '', expired); + response.cookies.set(OIDC_REDIRECT_COOKIE, '', expired); + response.cookies.set(OIDC_CALLBACK_URI_COOKIE, '', expired); return response; } diff --git a/src/app/api/oidc/callback/route.ts b/src/app/api/oidc/callback/route.ts new file mode 100644 index 0000000000..f682da79a7 --- /dev/null +++ b/src/app/api/oidc/callback/route.ts @@ -0,0 +1,146 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { applyAuthCookie, generateAuthCookie } from '@/lib/auth'; +import { + exchangeAuthorizationCode, + fetchUserInfo, + getOidcDiscovery, + getPublicBaseUrl, + isMultiUserStorage, + isOidcConfigured, + OIDC_CALLBACK_URI_COOKIE, + OIDC_FLOW_COOKIE_BASE, + OIDC_NONCE_COOKIE, + OIDC_REDIRECT_COOKIE, + OIDC_STATE_COOKIE, + OIDC_VERIFIER_COOKIE, + OidcError, + oidcLoginErrorPath, + provisionOidcUser, + resolveStoredRedirectUri, + sanitizeRedirect, + verifyIdToken, +} from '@/lib/oidc'; + +export const runtime = 'nodejs'; + +function clearOidcFlowCookies(response: NextResponse) { + const expired = { + ...OIDC_FLOW_COOKIE_BASE, + maxAge: 0, + expires: new Date(0), + }; + response.cookies.set(OIDC_STATE_COOKIE, '', expired); + response.cookies.set(OIDC_NONCE_COOKIE, '', expired); + response.cookies.set(OIDC_VERIFIER_COOKIE, '', expired); + response.cookies.set(OIDC_REDIRECT_COOKIE, '', expired); + response.cookies.set(OIDC_CALLBACK_URI_COOKIE, '', expired); +} + +function redirectToLogin( + request: NextRequest, + code: string, + fallbackRedirect?: string, + detail?: string +): NextResponse { + const response = NextResponse.redirect( + new URL( + oidcLoginErrorPath(code, fallbackRedirect, detail), + `${getPublicBaseUrl(request)}/` + ) + ); + clearOidcFlowCookies(response); + return response; +} + +export async function GET(request: NextRequest) { + const storedRedirect = sanitizeRedirect( + request.cookies.get(OIDC_REDIRECT_COOKIE)?.value + ); + const storedState = request.cookies.get(OIDC_STATE_COOKIE)?.value; + const storedNonce = request.cookies.get(OIDC_NONCE_COOKIE)?.value; + const storedVerifier = request.cookies.get(OIDC_VERIFIER_COOKIE)?.value; + + const idpError = request.nextUrl.searchParams.get('error'); + if (idpError) { + const denied = + idpError === 'access_denied' ? 'oidc_denied' : 'oidc_failed'; + return redirectToLogin(request, denied, storedRedirect); + } + + try { + if (!isOidcConfigured() || !isMultiUserStorage()) { + throw new OidcError('oidc_not_configured', '未启用 OIDC'); + } + if (!process.env.PASSWORD) { + throw new OidcError('oidc_not_configured', '未配置 PASSWORD,无法签发会话'); + } + + const code = request.nextUrl.searchParams.get('code'); + const state = request.nextUrl.searchParams.get('state'); + if (!code || !state) { + throw new OidcError('oidc_state', '缺少授权码或 state'); + } + if (!storedState || storedState !== state) { + throw new OidcError('oidc_state', 'OIDC state 校验失败'); + } + if (!storedNonce || !storedVerifier) { + throw new OidcError('oidc_state', 'OIDC 登录会话已过期,请重试'); + } + + const discovery = await getOidcDiscovery(); + const redirectUri = resolveStoredRedirectUri( + request, + request.cookies.get(OIDC_CALLBACK_URI_COOKIE)?.value + ); + const tokens = await exchangeAuthorizationCode({ + discovery, + code, + redirectUri, + codeVerifier: storedVerifier, + }); + + if (!tokens.id_token) { + throw new OidcError( + 'oidc_token', + '令牌响应缺少 id_token', + '身份提供方未返回 id_token,请确认 scope 包含 openid' + ); + } + + let claims = await verifyIdToken(tokens.id_token, discovery, storedNonce); + + const needUserInfo = + !claims[process.env.OIDC_USERNAME_CLAIM || 'preferred_username'] && + !claims.preferred_username && + !claims.email && + !claims.name; + if (needUserInfo && tokens.access_token) { + const extra = await fetchUserInfo(discovery, tokens.access_token); + claims = { ...extra, ...claims }; + } + + const identity = await provisionOidcUser(claims); + const cookieValue = await generateAuthCookie( + identity.username, + undefined, + identity.role, + false, + 'oidc' + ); + + const response = NextResponse.redirect( + new URL(storedRedirect, `${getPublicBaseUrl(request)}/`) + ); + applyAuthCookie(response, cookieValue); + clearOidcFlowCookies(response); + return response; + } catch (error) { + const code = error instanceof OidcError ? error.code : 'oidc_failed'; + const detail = error instanceof OidcError ? error.detail : undefined; + console.error('OIDC 回调处理失败:', error); + return redirectToLogin(request, code, storedRedirect, detail); + } +} diff --git a/src/app/api/oidc/login/route.ts b/src/app/api/oidc/login/route.ts new file mode 100644 index 0000000000..e0b70ba7c2 --- /dev/null +++ b/src/app/api/oidc/login/route.ts @@ -0,0 +1,85 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { + buildAuthorizationUrl, + createPkcePair, + generateOidcRandomString, + getOidcDiscovery, + getOidcRedirectUri, + getPublicBaseUrl, + isMultiUserStorage, + isOidcConfigured, + OIDC_CALLBACK_URI_COOKIE, + OIDC_FLOW_COOKIE_BASE, + OIDC_NONCE_COOKIE, + OIDC_REDIRECT_COOKIE, + OIDC_STATE_COOKIE, + OIDC_VERIFIER_COOKIE, + OidcError, + oidcLoginErrorPath, + sanitizeRedirect, +} from '@/lib/oidc'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + const requestedRedirect = sanitizeRedirect( + request.nextUrl.searchParams.get('redirect') + ); + + try { + if (!isOidcConfigured()) { + throw new OidcError('oidc_not_configured', '未启用 OIDC'); + } + if (!isMultiUserStorage()) { + throw new OidcError( + 'oidc_storage', + 'OIDC 仅支持 redis / kvrocks / upstash 存储模式' + ); + } + if (!process.env.PASSWORD) { + throw new OidcError('oidc_not_configured', '未配置 PASSWORD,无法签发会话'); + } + + const discovery = await getOidcDiscovery(); + const redirectUri = getOidcRedirectUri(request); + const state = generateOidcRandomString(24); + const nonce = generateOidcRandomString(24); + const { verifier, challenge } = createPkcePair(); + const authorizationUrl = buildAuthorizationUrl({ + discovery, + redirectUri, + state, + nonce, + codeChallenge: challenge, + }); + + const response = NextResponse.redirect(authorizationUrl); + response.cookies.set(OIDC_STATE_COOKIE, state, OIDC_FLOW_COOKIE_BASE); + response.cookies.set(OIDC_NONCE_COOKIE, nonce, OIDC_FLOW_COOKIE_BASE); + response.cookies.set(OIDC_VERIFIER_COOKIE, verifier, OIDC_FLOW_COOKIE_BASE); + response.cookies.set( + OIDC_REDIRECT_COOKIE, + requestedRedirect, + OIDC_FLOW_COOKIE_BASE + ); + response.cookies.set( + OIDC_CALLBACK_URI_COOKIE, + redirectUri, + OIDC_FLOW_COOKIE_BASE + ); + return response; + } catch (error) { + const code = + error instanceof OidcError ? error.code : 'oidc_failed'; + console.error('OIDC 登录发起失败:', error); + return NextResponse.redirect( + new URL( + oidcLoginErrorPath(code, requestedRedirect), + `${getPublicBaseUrl(request)}/` + ) + ); + } +} diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts index 011a0ffb22..da185d5c71 100644 --- a/src/app/api/server-config/route.ts +++ b/src/app/api/server-config/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; +import { getPublicOidcConfig } from '@/lib/oidc'; import { CURRENT_VERSION } from '@/lib/version' export const runtime = 'nodejs'; @@ -15,6 +16,7 @@ export async function GET(request: NextRequest) { SiteName: config.SiteConfig.SiteName, StorageType: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage', Version: CURRENT_VERSION, + Oidc: getPublicOidcConfig(), }; return NextResponse.json(result); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 51ad3a93c9..81fb63dfce 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { Inter } from 'next/font/google'; import './globals.css'; import { getConfig } from '@/lib/config'; +import { getPublicOidcConfig } from '@/lib/oidc'; import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator'; import { SiteProvider } from '../components/SiteProvider'; @@ -92,6 +93,7 @@ export default async function RootLayout({ CUSTOM_CATEGORIES: customCategories, FLUID_SEARCH: fluidSearch, ENABLE_WEB_LIVE: enableWebLive, + OIDC: getPublicOidcConfig(), }; return ( diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a0f2e72991..e3a3cae7ae 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -67,6 +67,20 @@ function VersionDisplay() { ); } +const OIDC_ERROR_MESSAGES: Record = { + oidc_not_configured: 'OIDC 未正确配置', + oidc_storage: '当前存储模式不支持 OIDC,请使用 redis / kvrocks / upstash', + oidc_discovery: '无法连接身份提供方,请检查 OIDC_ISSUER', + oidc_state: '登录状态已失效,请重新发起 OIDC 登录', + oidc_nonce: 'OIDC 校验失败,请重试', + oidc_denied: '已取消 OIDC 登录', + oidc_banned: '该账号已被封禁', + oidc_not_registered: '账号未开通,请联系管理员先创建用户', + oidc_username: '无法从身份提供方解析用户名', + oidc_token: 'OIDC 换取令牌失败,请检查回调地址、Client Secret 和客户端认证方式', + oidc_failed: 'OIDC 登录失败,请稍后重试', +}; + function LoginPageClient() { const router = useRouter(); const searchParams = useSearchParams(); @@ -75,21 +89,41 @@ function LoginPageClient() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [shouldAskUsername, setShouldAskUsername] = useState(false); + const [oidcEnabled, setOidcEnabled] = useState(false); + const [oidcButtonText, setOidcButtonText] = useState('OIDC 登录'); + const [disablePassword, setDisablePassword] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); const { siteName } = useSite(); // 在客户端挂载后设置配置 useEffect(() => { if (typeof window !== 'undefined') { - const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE; + const runtimeConfig = (window as any).RUNTIME_CONFIG; + const storageType = runtimeConfig?.STORAGE_TYPE; setShouldAskUsername(storageType && storageType !== 'localstorage'); + setOidcEnabled(Boolean(runtimeConfig?.OIDC?.enabled)); + if (runtimeConfig?.OIDC?.buttonText) { + setOidcButtonText(runtimeConfig.OIDC.buttonText); + } + setDisablePassword(Boolean(runtimeConfig?.OIDC?.disablePassword)); } }, []); + useEffect(() => { + const oidcError = searchParams.get('error'); + if (oidcError) { + const detail = searchParams.get('error_detail'); + const base = OIDC_ERROR_MESSAGES[oidcError] || 'OIDC 登录失败,请稍后重试'; + setError(detail ? `${base}(${detail})` : base); + } + }, [searchParams]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); + if (disablePassword && oidcEnabled) return; if (!password || (shouldAskUsername && !username)) return; try { @@ -119,7 +153,18 @@ function LoginPageClient() { } }; + const handleOidcLogin = () => { + setError(null); + setOidcLoading(true); + const redirect = searchParams.get('redirect') || '/'; + const oidcUrl = new URL('/api/oidc/login', window.location.origin); + if (redirect && redirect !== '/') { + oidcUrl.searchParams.set('redirect', redirect); + } + window.location.href = oidcUrl.toString(); + }; + const showPasswordForm = !oidcEnabled || !disablePassword; return (
@@ -131,52 +176,82 @@ function LoginPageClient() { {siteName}
- {shouldAskUsername && ( -
- - setUsername(e.target.value)} - /> + {oidcEnabled && ( + + )} + + {oidcEnabled && showPasswordForm && ( +
+
+
+
+
+ + 或使用账号密码 + +
)} -
- - setPassword(e.target.value)} - /> -
+ {showPasswordForm && ( + <> + {shouldAskUsername && ( +
+ + setUsername(e.target.value)} + /> +
+ )} + +
+ + setPassword(e.target.value)} + /> +
+ + )} {error && (

{error}

)} {/* 登录按钮 */} - + {showPasswordForm && ( + + )}
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 8889562390..54a885c9f5 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -26,6 +26,7 @@ import { VersionPanel } from './VersionPanel'; interface AuthInfo { username?: string; role?: 'owner' | 'admin' | 'user'; + authMethod?: 'password' | 'oidc'; } export const UserMenu: React.FC = () => { @@ -460,7 +461,9 @@ export const UserMenu: React.FC = () => { // 检查是否显示修改密码按钮 const showChangePassword = - authInfo?.role !== 'owner' && storageType !== 'localstorage'; + authInfo?.role !== 'owner' && + storageType !== 'localstorage' && + authInfo?.authMethod !== 'oidc'; // 角色中文映射 const getRoleText = (role?: string) => { diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index a79857ef94..daabecf6b6 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -25,6 +25,8 @@ export interface AdminConfig { banned?: boolean; enabledApis?: string[]; // 优先级高于tags限制 tags?: string[]; // 多 tags 取并集限制 + from?: 'local' | 'oidc'; + oidcSub?: string; }[]; Tags?: { name: string; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 463ae9e0f9..1ffa27cdc6 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,12 +1,102 @@ -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; -// 从cookie获取认证信息 (服务端使用) -export function getAuthInfoFromCookie(request: NextRequest): { +export type AuthRole = 'owner' | 'admin' | 'user'; +export type AuthMethod = 'password' | 'oidc'; + +export interface AuthInfo { password?: string; username?: string; signature?: string; timestamp?: number; -} | null { + role?: AuthRole; + authMethod?: AuthMethod; +} + +export const AUTH_COOKIE_NAME = 'auth'; + +export const AUTH_COOKIE_BASE = { + path: '/', + sameSite: 'lax' as const, + httpOnly: false, // PWA 需要客户端可访问 + secure: false, +}; + +// 生成 HMAC-SHA256 签名 +export async function generateSignature( + data: string, + secret: string +): Promise { + const encoder = new TextEncoder(); + const keyData = encoder.encode(secret); + const messageData = encoder.encode(data); + + const key = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', key, messageData); + + return Array.from(new Uint8Array(signature)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +// 生成认证 Cookie(带签名),与现有登录接口格式保持一致 +export async function generateAuthCookie( + username?: string, + password?: string, + role?: AuthRole, + includePassword = false, + authMethod?: AuthMethod +): Promise { + const authData: AuthInfo = { role: role || 'user' }; + + if (includePassword && password) { + authData.password = password; + } + + if (username && process.env.PASSWORD) { + authData.username = username; + authData.signature = await generateSignature( + username, + process.env.PASSWORD + ); + authData.timestamp = Date.now(); + } + + if (authMethod) { + authData.authMethod = authMethod; + } + + return encodeURIComponent(JSON.stringify(authData)); +} + +export function applyAuthCookie( + response: NextResponse, + cookieValue: string, + maxAgeDays = 7 +): void { + const expires = new Date(); + expires.setDate(expires.getDate() + maxAgeDays); + response.cookies.set(AUTH_COOKIE_NAME, cookieValue, { + ...AUTH_COOKIE_BASE, + expires, + }); +} + +export function clearAuthCookie(response: NextResponse): void { + response.cookies.set(AUTH_COOKIE_NAME, '', { + ...AUTH_COOKIE_BASE, + expires: new Date(0), + }); +} + +// 从cookie获取认证信息 (服务端使用) +export function getAuthInfoFromCookie(request: NextRequest): AuthInfo | null { const authCookie = request.cookies.get('auth'); if (!authCookie) { @@ -23,13 +113,7 @@ export function getAuthInfoFromCookie(request: NextRequest): { } // 从cookie获取认证信息 (客户端使用) -export function getAuthInfoFromBrowserCookie(): { - password?: string; - username?: string; - signature?: string; - timestamp?: number; - role?: 'owner' | 'admin' | 'user'; -} | null { +export function getAuthInfoFromBrowserCookie(): AuthInfo | null { if (typeof window === 'undefined') { return null; } diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index e2dff649cf..4bd727f820 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -10,6 +10,19 @@ export interface ChangelogEntry { } export const changelog: ChangelogEntry[] = [ + { + version: "100.1.4", + date: "2026-08-18", + added: [ + "支持标准 OIDC 登录,并映射到现有用户系统(收藏、播放记录、角色权限保持不变)" + ], + changed: [ + // 无变更内容 + ], + fixed: [ + // 无修复内容 + ] + }, { version: "100.1.3", date: "2026-05-28", diff --git a/src/lib/config.ts b/src/lib/config.ts index e8947b3dbc..7bfd1772ee 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -360,6 +360,8 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { banned: false, enabledApis: originOwnerCfg?.enabledApis || undefined, tags: originOwnerCfg?.tags || undefined, + from: originOwnerCfg?.from, + oidcSub: originOwnerCfg?.oidcSub, }); // 采集源去重 diff --git a/src/lib/oidc.test.ts b/src/lib/oidc.test.ts new file mode 100644 index 0000000000..9b24bfbb06 --- /dev/null +++ b/src/lib/oidc.test.ts @@ -0,0 +1,368 @@ +/** + * @jest-environment node + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { getConfig, setCachedConfig } from '@/lib/config'; +import { db } from '@/lib/db'; +import { + getOidcScopes, + getPublicBaseUrl, + getPublicOidcConfig, + getRequestOrigin, + isMultiUserStorage, + isOidcAutoRegister, + isOidcConfigured, + OidcError, + oidcLoginErrorPath, + parseTokenResponseBody, + pickTokenClientAuth, + provisionOidcUser, + resolveOidcUsername, + resolveStoredRedirectUri, + sanitizeRedirect, + sanitizeUsername, +} from '@/lib/oidc'; + +function fakeRequest( + url: string, + headers: Record = {} +): { + headers: { get(name: string): string | null }; + nextUrl: { protocol: string; host: string }; +} { + const parsed = new URL(url); + return { + nextUrl: { protocol: parsed.protocol, host: parsed.host }, + headers: { + get(name: string) { + return headers[name] || headers[name.toLowerCase()] || null; + }, + }, + }; +} + +jest.mock('@/lib/config', () => ({ + getConfig: jest.fn(), + setCachedConfig: jest.fn(), +})); + +jest.mock('@/lib/db', () => ({ + db: { + saveAdminConfig: jest.fn(), + checkUserExist: jest.fn(), + registerUser: jest.fn(), + }, +})); + +const mockedGetConfig = getConfig as jest.MockedFunction; +const mockedSetCachedConfig = setCachedConfig as jest.MockedFunction< + typeof setCachedConfig +>; +const mockedDb = db as jest.Mocked; + +describe('oidc helpers', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + delete process.env.OIDC_ENABLED; + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_AUTO_REGISTER; + delete process.env.OIDC_DISABLE_PASSWORD; + delete process.env.OIDC_SCOPES; + delete process.env.OIDC_USERNAME_CLAIM; + delete process.env.OIDC_BUTTON_TEXT; + delete process.env.OIDC_CLIENT_AUTH; + delete process.env.OIDC_REDIRECT_URI; + delete process.env.SITE_BASE; + delete process.env.NEXT_PUBLIC_STORAGE_TYPE; + delete process.env.USERNAME; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + test('isOidcConfigured requires issuer and client id', () => { + expect(isOidcConfigured()).toBe(false); + process.env.OIDC_ISSUER = 'https://auth.example.com'; + expect(isOidcConfigured()).toBe(false); + process.env.OIDC_CLIENT_ID = 'lunatv'; + expect(isOidcConfigured()).toBe(true); + }); + + test('isOidcConfigured can be explicitly disabled', () => { + process.env.OIDC_ISSUER = 'https://auth.example.com'; + process.env.OIDC_CLIENT_ID = 'lunatv'; + process.env.OIDC_ENABLED = 'false'; + expect(isOidcConfigured()).toBe(false); + }); + + test('getPublicOidcConfig is disabled on localstorage', () => { + process.env.OIDC_ISSUER = 'https://auth.example.com'; + process.env.OIDC_CLIENT_ID = 'lunatv'; + process.env.NEXT_PUBLIC_STORAGE_TYPE = 'localstorage'; + expect(getPublicOidcConfig()).toEqual({ + enabled: false, + buttonText: 'OIDC 登录', + disablePassword: false, + }); + }); + + test('getPublicOidcConfig exposes button text in multi-user mode', () => { + process.env.OIDC_ISSUER = 'https://auth.example.com'; + process.env.OIDC_CLIENT_ID = 'lunatv'; + process.env.NEXT_PUBLIC_STORAGE_TYPE = 'redis'; + process.env.OIDC_BUTTON_TEXT = '使用公司账号登录'; + process.env.OIDC_DISABLE_PASSWORD = 'true'; + expect(getPublicOidcConfig()).toEqual({ + enabled: true, + buttonText: '使用公司账号登录', + disablePassword: true, + }); + expect(isMultiUserStorage()).toBe(true); + }); + + test('getOidcScopes always includes openid', () => { + process.env.OIDC_SCOPES = 'profile email'; + expect(getOidcScopes()).toBe('openid profile email'); + }); + + test('sanitizeUsername strips path separators', () => { + expect(sanitizeUsername(' alice/bob ')).toBe('alice_bob'); + expect(sanitizeUsername('user@example.com')).toBe('user@example.com'); + }); + + test('resolveOidcUsername prefers configured claim then fallbacks', () => { + expect( + resolveOidcUsername( + { + preferred_username: 'bob', + email: 'bob@example.com', + sub: 'abc', + }, + 'preferred_username' + ) + ).toBe('bob'); + expect( + resolveOidcUsername( + { + email: 'bob@example.com', + sub: 'abc', + }, + 'preferred_username' + ) + ).toBe('bob@example.com'); + expect(resolveOidcUsername({ sub: 'abc-123' })).toBe('abc-123'); + expect(() => resolveOidcUsername({})).toThrow(OidcError); + }); + + test('getPublicBaseUrl uses SITE_BASE then OIDC_REDIRECT_URI origin', () => { + const request = fakeRequest('http://127.0.0.1:3001/api/oidc/callback', { + host: 'tv.impengbo.cn', + }); + process.env.OIDC_REDIRECT_URI = + 'https://tv.impengbo.cn:8443/api/oidc/callback'; + expect(getPublicBaseUrl(request)).toBe('https://tv.impengbo.cn:8443'); + process.env.SITE_BASE = 'https://tv.impengbo.cn:8443'; + expect(getPublicBaseUrl(request)).toBe('https://tv.impengbo.cn:8443'); + }); + + test('getRequestOrigin prefers forwarded proto and host', () => { + const request = fakeRequest('http://127.0.0.1:3000/api/oidc/login', { + host: '0.0.0.0:3001', + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'tv.example.com', + }); + expect(getRequestOrigin(request)).toBe('https://tv.example.com'); + }); + + test('pickTokenClientAuth prefers post for Authelia-style clients', () => { + expect(pickTokenClientAuth({}, false)).toBe('none'); + expect(pickTokenClientAuth({}, true)).toBe('post'); + expect( + pickTokenClientAuth( + { token_endpoint_auth_methods_supported: ['client_secret_post'] }, + true + ) + ).toBe('post'); + expect( + pickTokenClientAuth( + { + token_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'client_secret_post', + ], + }, + true + ) + ).toBe('post'); + expect( + pickTokenClientAuth( + { token_endpoint_auth_methods_supported: ['client_secret_basic'] }, + true + ) + ).toBe('basic'); + process.env.OIDC_CLIENT_AUTH = 'basic'; + expect(pickTokenClientAuth({}, true)).toBe('basic'); + }); + + test('parseTokenResponseBody accepts json and form bodies', () => { + expect( + parseTokenResponseBody( + '{"id_token":"abc"}', + 'application/json; charset=utf-8' + ) + ).toEqual({ id_token: 'abc' }); + expect( + parseTokenResponseBody( + 'id_token=abc&token_type=Bearer', + 'application/x-www-form-urlencoded' + ) + ).toEqual({ id_token: 'abc', token_type: 'Bearer' }); + }); + + test('resolveStoredRedirectUri keeps the authorize-time callback', () => { + const request = fakeRequest('http://127.0.0.1:3000/api/oidc/callback') as any; + expect( + resolveStoredRedirectUri( + request, + 'https://0.0.0.0:3001/api/oidc/callback' + ) + ).toBe('https://0.0.0.0:3001/api/oidc/callback'); + }); + + test('oidcLoginErrorPath includes sanitized detail', () => { + expect(oidcLoginErrorPath('oidc_token', '/', 'invalid_grant\nfoo')).toBe( + '/login?error=oidc_token&error_detail=invalid_grant+foo' + ); + }); + + test('sanitizeRedirect only allows relative paths', () => { + expect(sanitizeRedirect('/play?id=1')).toBe('/play?id=1'); + expect(sanitizeRedirect('https://evil.example')).toBe('/'); + expect(sanitizeRedirect('//evil.example')).toBe('/'); + expect(sanitizeRedirect(undefined)).toBe('/'); + }); + + test('provisionOidcUser binds existing user by username', async () => { + process.env.USERNAME = 'admin'; + const config: any = { + UserConfig: { + Users: [{ username: 'bob', role: 'user', banned: false }], + }, + }; + mockedGetConfig.mockResolvedValue(config); + + const identity = await provisionOidcUser({ + sub: 'oidc-1', + preferred_username: 'bob', + }); + + expect(identity).toEqual({ + username: 'bob', + role: 'user', + sub: 'oidc-1', + }); + expect(config.UserConfig.Users[0].oidcSub).toBe('oidc-1'); + expect(mockedDb.saveAdminConfig).toHaveBeenCalled(); + expect(mockedSetCachedConfig).toHaveBeenCalled(); + }); + + test('provisionOidcUser maps owner by USERNAME', async () => { + process.env.USERNAME = 'admin'; + const config: any = { + UserConfig: { Users: [{ username: 'admin', role: 'owner' }] }, + }; + mockedGetConfig.mockResolvedValue(config); + + const identity = await provisionOidcUser({ + sub: 'owner-sub', + preferred_username: 'admin', + }); + + expect(identity.role).toBe('owner'); + expect(config.UserConfig.Users[0].oidcSub).toBe('owner-sub'); + expect(mockedDb.registerUser).not.toHaveBeenCalled(); + expect(mockedDb.saveAdminConfig).toHaveBeenCalled(); + }); + + test('provisionOidcUser rejects banned users', async () => { + mockedGetConfig.mockResolvedValue({ + UserConfig: { + Users: [{ username: 'bob', role: 'user', banned: true }], + }, + } as any); + + await expect( + provisionOidcUser({ sub: 'x', preferred_username: 'bob' }) + ).rejects.toMatchObject({ code: 'oidc_banned' }); + }); + + test('provisionOidcUser auto-registers new users', async () => { + process.env.USERNAME = 'admin'; + process.env.OIDC_AUTO_REGISTER = 'true'; + const config: any = { UserConfig: { Users: [] } }; + mockedGetConfig.mockResolvedValue(config); + mockedDb.checkUserExist.mockResolvedValue(false); + mockedDb.registerUser.mockResolvedValue(); + + const identity = await provisionOidcUser({ + sub: 'new-sub', + preferred_username: 'carol', + }); + + expect(identity).toEqual({ + username: 'carol', + role: 'user', + sub: 'new-sub', + }); + expect(mockedDb.registerUser).toHaveBeenCalled(); + expect(config.UserConfig.Users[0]).toMatchObject({ + username: 'carol', + from: 'oidc', + oidcSub: 'new-sub', + }); + }); + + test('provisionOidcUser respects disabled auto-register', async () => { + process.env.OIDC_AUTO_REGISTER = 'false'; + expect(isOidcAutoRegister()).toBe(false); + mockedGetConfig.mockResolvedValue({ UserConfig: { Users: [] } } as any); + + await expect( + provisionOidcUser({ sub: 'new-sub', preferred_username: 'dave' }) + ).rejects.toMatchObject({ code: 'oidc_not_registered' }); + expect(mockedDb.registerUser).not.toHaveBeenCalled(); + }); + + test('provisionOidcUser prefers stable oidcSub over claim rename', async () => { + mockedGetConfig.mockResolvedValue({ + UserConfig: { + Users: [ + { + username: 'oldname', + role: 'admin', + oidcSub: 'stable-sub', + from: 'oidc', + }, + ], + }, + } as any); + + const identity = await provisionOidcUser({ + sub: 'stable-sub', + preferred_username: 'newname', + }); + + expect(identity).toEqual({ + username: 'oldname', + role: 'admin', + sub: 'stable-sub', + }); + expect(mockedDb.registerUser).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts new file mode 100644 index 0000000000..dc50bb8d61 --- /dev/null +++ b/src/lib/oidc.ts @@ -0,0 +1,604 @@ +/* eslint-disable no-console, @typescript-eslint/no-explicit-any */ + +import { createHash, randomBytes } from 'crypto'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { NextRequest } from 'next/server'; + +import { getConfig, setCachedConfig } from '@/lib/config'; +import { db } from '@/lib/db'; + +import { AuthRole } from './auth'; + +export const OIDC_STATE_COOKIE = 'oidc_state'; +export const OIDC_NONCE_COOKIE = 'oidc_nonce'; +export const OIDC_VERIFIER_COOKIE = 'oidc_verifier'; +export const OIDC_REDIRECT_COOKIE = 'oidc_redirect'; +export const OIDC_CALLBACK_URI_COOKIE = 'oidc_callback_uri'; + +export type OidcClientAuthMethod = 'basic' | 'post' | 'none'; + +export const OIDC_FLOW_COOKIE_BASE = { + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + secure: false, + maxAge: 10 * 60, +}; + +const DISCOVERY_TTL_MS = 60 * 60 * 1000; +const CLOCK_TOLERANCE_SEC = 30; +const USERNAME_MAX_LEN = 64; + +export interface OidcDiscovery { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + jwks_uri: string; + userinfo_endpoint?: string; + end_session_endpoint?: string; + token_endpoint_auth_methods_supported?: string[]; +} + +export interface PublicOidcConfig { + enabled: boolean; + buttonText: string; + disablePassword: boolean; +} + +export interface OidcUserIdentity { + username: string; + role: AuthRole; + sub: string; +} + +export class OidcError extends Error { + code: string; + detail?: string; + + constructor(code: string, message: string, detail?: string) { + super(message); + this.name = 'OidcError'; + this.code = code; + this.detail = detail; + } +} + +interface CachedDiscovery { + data: OidcDiscovery; + fetchedAt: number; + issuer: string; +} + +let cachedDiscovery: CachedDiscovery | null = null; + +function envFlag(name: string, defaultValue = false): boolean { + const raw = process.env[name]; + if (raw === undefined || raw === '') { + return defaultValue; + } + return raw === 'true' || raw === '1'; +} + +export function getStorageType(): string { + return process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; +} + +export function isMultiUserStorage(): boolean { + const storageType = getStorageType(); + return ( + storageType === 'redis' || + storageType === 'kvrocks' || + storageType === 'upstash' + ); +} + +export function isOidcConfigured(): boolean { + if (process.env.OIDC_ENABLED === 'false') { + return false; + } + return Boolean(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID); +} + +export function getPublicOidcConfig(): PublicOidcConfig { + return { + enabled: isOidcConfigured() && isMultiUserStorage(), + buttonText: process.env.OIDC_BUTTON_TEXT || 'OIDC 登录', + disablePassword: envFlag('OIDC_DISABLE_PASSWORD', false), + }; +} + +export function getOidcScopes(): string { + const raw = process.env.OIDC_SCOPES || 'openid profile email'; + const parts = raw.split(/[\s,]+/).filter(Boolean); + if (!parts.includes('openid')) { + parts.unshift('openid'); + } + return Array.from(new Set(parts)).join(' '); +} + +export function getUsernameClaimName(): string { + return process.env.OIDC_USERNAME_CLAIM || 'preferred_username'; +} + +export function isOidcAutoRegister(): boolean { + return envFlag('OIDC_AUTO_REGISTER', true); +} + +export function sanitizeRedirect(redirect: string | null | undefined): string { + if (!redirect) { + return '/'; + } + const trimmed = redirect.trim(); + if (!trimmed.startsWith('/') || trimmed.startsWith('//')) { + return '/'; + } + return trimmed; +} + +function firstHeaderValue(value: string | null): string | null { + if (!value) { + return null; + } + return value.split(',')[0].trim() || null; +} + +export function getRequestOrigin(request: { + headers: { get(name: string): string | null }; + nextUrl: { protocol: string; host: string }; +}): string { + const proto = + firstHeaderValue(request.headers.get('x-forwarded-proto')) || + request.nextUrl.protocol.replace(':', '') || + 'http'; + const host = + firstHeaderValue(request.headers.get('x-forwarded-host')) || + request.headers.get('host') || + request.nextUrl.host; + return `${proto}://${host}`; +} + +export function getPublicBaseUrl(request: { + headers: { get(name: string): string | null }; + nextUrl: { protocol: string; host: string }; +}): string { + const siteBase = process.env.SITE_BASE?.trim(); + if (siteBase) { + return siteBase.replace(/\/+$/, ''); + } + const explicit = process.env.OIDC_REDIRECT_URI?.trim(); + if (explicit) { + try { + return new URL(explicit).origin; + } catch { + // ignore invalid override + } + } + return getRequestOrigin(request); +} + +export function getOidcRedirectUri(request: NextRequest): string { + const explicit = process.env.OIDC_REDIRECT_URI?.trim(); + if (explicit) { + return explicit; + } + return `${getPublicBaseUrl(request)}/api/oidc/callback`; +} + +export function resolveStoredRedirectUri( + request: NextRequest, + stored?: string | null +): string { + if (stored && /^https?:\/\/\S+$/i.test(stored)) { + return stored; + } + return getOidcRedirectUri(request); +} + +export function pickTokenClientAuth( + discovery: Pick, + hasSecret: boolean +): OidcClientAuthMethod { + const override = process.env.OIDC_CLIENT_AUTH?.trim().toLowerCase(); + if (override === 'basic' || override === 'post' || override === 'none') { + return override; + } + if (!hasSecret) { + return 'none'; + } + const methods = discovery.token_endpoint_auth_methods_supported; + // Authelia 等会在发现文档里同时列出 basic/post,但单个 client 常被固定为 + // client_secret_post。双方都支持时优先 post,避免 invalid_client。 + if (!methods || methods.length === 0) { + return 'post'; + } + if (methods.includes('client_secret_post')) { + return 'post'; + } + if (methods.includes('client_secret_basic')) { + return 'basic'; + } + return 'post'; +} + +function getDiscoveryUrl(issuer: string): string { + const normalized = issuer.replace(/\/+$/, ''); + if (normalized.includes('/.well-known/')) { + return issuer; + } + return `${normalized}/.well-known/openid-configuration`; +} + +export async function getOidcDiscovery(): Promise { + const issuer = process.env.OIDC_ISSUER?.trim(); + if (!issuer) { + throw new OidcError('oidc_not_configured', '未配置 OIDC_ISSUER'); + } + + if ( + cachedDiscovery && + cachedDiscovery.issuer === issuer && + Date.now() - cachedDiscovery.fetchedAt < DISCOVERY_TTL_MS + ) { + return cachedDiscovery.data; + } + + const discoveryUrl = getDiscoveryUrl(issuer); + const res = await fetch(discoveryUrl, { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + if (!res.ok) { + throw new OidcError( + 'oidc_discovery', + `无法获取 OIDC 发现文档 (${res.status})` + ); + } + + const data = (await res.json()) as OidcDiscovery; + if ( + !data.issuer || + !data.authorization_endpoint || + !data.token_endpoint || + !data.jwks_uri + ) { + throw new OidcError('oidc_discovery', 'OIDC 发现文档缺少必要字段'); + } + + cachedDiscovery = { + data, + fetchedAt: Date.now(), + issuer, + }; + return data; +} + +export function generateOidcRandomString(bytes = 32): string { + return base64UrlEncode(randomBytes(bytes)); +} + +export function createPkcePair(): { verifier: string; challenge: string } { + const verifier = generateOidcRandomString(32); + const challenge = base64UrlEncode( + createHash('sha256').update(verifier).digest() + ); + return { verifier, challenge }; +} + +function base64UrlEncode(buf: Buffer): string { + return buf + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +export function buildAuthorizationUrl(params: { + discovery: OidcDiscovery; + redirectUri: string; + state: string; + nonce: string; + codeChallenge: string; +}): string { + const clientId = process.env.OIDC_CLIENT_ID; + if (!clientId) { + throw new OidcError('oidc_not_configured', '未配置 OIDC_CLIENT_ID'); + } + + const url = new URL(params.discovery.authorization_endpoint); + url.searchParams.set('client_id', clientId); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', getOidcScopes()); + url.searchParams.set('redirect_uri', params.redirectUri); + url.searchParams.set('state', params.state); + url.searchParams.set('nonce', params.nonce); + url.searchParams.set('code_challenge', params.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + return url.toString(); +} + +export function parseTokenResponseBody( + text: string, + contentType: string +): Record { + if (contentType.includes('application/x-www-form-urlencoded')) { + return Object.fromEntries(new URLSearchParams(text)); + } + if (!text) { + return {}; + } + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return { + error: 'invalid_response', + error_description: text.slice(0, 200), + }; + } +} + +export async function exchangeAuthorizationCode(params: { + discovery: OidcDiscovery; + code: string; + redirectUri: string; + codeVerifier: string; +}): Promise<{ id_token?: string; access_token?: string }> { + const clientId = process.env.OIDC_CLIENT_ID; + if (!clientId) { + throw new OidcError('oidc_not_configured', '未配置 OIDC_CLIENT_ID'); + } + + const clientSecret = process.env.OIDC_CLIENT_SECRET || ''; + const authMethod = pickTokenClientAuth(params.discovery, Boolean(clientSecret)); + + const body = new URLSearchParams(); + body.set('grant_type', 'authorization_code'); + body.set('code', params.code); + body.set('redirect_uri', params.redirectUri); + body.set('code_verifier', params.codeVerifier); + + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + if (authMethod === 'basic' && clientSecret) { + headers.Authorization = `Basic ${Buffer.from( + `${clientId}:${clientSecret}`, + 'utf8' + ).toString('base64')}`; + } else { + body.set('client_id', clientId); + if (authMethod === 'post' && clientSecret) { + body.set('client_secret', clientSecret); + } + } + + const res = await fetch(params.discovery.token_endpoint, { + method: 'POST', + headers, + body, + cache: 'no-store', + }); + + const raw = await res.text(); + const payload = parseTokenResponseBody( + raw, + res.headers.get('content-type') || '' + ); + if (!res.ok) { + const description = + payload.error_description || payload.error || `HTTP ${res.status}`; + console.error('OIDC 换取令牌失败:', { + status: res.status, + error: payload.error, + error_description: payload.error_description, + redirectUri: params.redirectUri, + tokenEndpoint: params.discovery.token_endpoint, + authMethod, + }); + throw new OidcError('oidc_token', `换取令牌失败: ${description}`, description); + } + + return payload; +} + +export async function verifyIdToken( + idToken: string, + discovery: OidcDiscovery, + expectedNonce: string +): Promise> { + const clientId = process.env.OIDC_CLIENT_ID; + if (!clientId) { + throw new OidcError('oidc_not_configured', '未配置 OIDC_CLIENT_ID'); + } + + const JWKS = createRemoteJWKSet(new URL(discovery.jwks_uri)); + const { payload } = await jwtVerify(idToken, JWKS, { + issuer: discovery.issuer, + audience: clientId, + clockTolerance: CLOCK_TOLERANCE_SEC, + }); + + if (!payload.nonce || payload.nonce !== expectedNonce) { + throw new OidcError('oidc_nonce', 'OIDC nonce 校验失败'); + } + if (!payload.sub || typeof payload.sub !== 'string') { + throw new OidcError('oidc_claims', 'OIDC 令牌缺少 sub'); + } + + return payload as Record; +} + +export async function fetchUserInfo( + discovery: OidcDiscovery, + accessToken: string +): Promise> { + if (!discovery.userinfo_endpoint) { + return {}; + } + const res = await fetch(discovery.userinfo_endpoint, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }); + if (!res.ok) { + return {}; + } + const data = await res.json().catch(() => ({})); + return data && typeof data === 'object' ? data : {}; +} + +export function sanitizeUsername(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + throw new OidcError('oidc_username', '用户名为空'); + } + + // 邮箱保留 @ 与 .,其余空白和路径分隔符替换为下划线,避免破坏存储 key + const sanitized = trimmed + .replace(/[:/\\]+/g, '_') + .replace(/\s+/g, '_') + .slice(0, USERNAME_MAX_LEN); + + if (!sanitized) { + throw new OidcError('oidc_username', '用户名非法'); + } + return sanitized; +} + +export function resolveOidcUsername( + claims: Record, + claimName = getUsernameClaimName() +): string { + const preferred = [claimName, 'preferred_username', 'email', 'name', 'sub']; + const seen = new Set(); + + for (const key of preferred) { + if (seen.has(key)) continue; + seen.add(key); + const value = claims[key]; + if (typeof value === 'string' && value.trim()) { + return sanitizeUsername(value); + } + } + + throw new OidcError('oidc_username', '无法从 OIDC 令牌解析用户名'); +} + +export async function provisionOidcUser( + claims: Record +): Promise { + const sub = typeof claims.sub === 'string' ? claims.sub : ''; + if (!sub) { + throw new OidcError('oidc_claims', 'OIDC 令牌缺少 sub'); + } + + const mappedUsername = resolveOidcUsername(claims); + const config = await getConfig(); + const users = config.UserConfig.Users; + + const bySub = users.find((u) => u.oidcSub === sub); + if (bySub) { + if (bySub.banned) { + throw new OidcError('oidc_banned', '用户被封禁'); + } + return { + username: bySub.username, + role: bySub.role, + sub, + }; + } + + if (mappedUsername === process.env.USERNAME) { + const owner = users.find((u) => u.username === mappedUsername); + if (owner && owner.oidcSub !== sub) { + owner.oidcSub = sub; + if (!owner.from) { + owner.from = 'oidc'; + } + await db.saveAdminConfig(config); + setCachedConfig(config); + } + return { + username: mappedUsername, + role: 'owner', + sub, + }; + } + + const byName = users.find((u) => u.username === mappedUsername); + if (byName) { + if (byName.banned) { + throw new OidcError('oidc_banned', '用户被封禁'); + } + byName.oidcSub = sub; + if (!byName.from) { + byName.from = 'oidc'; + } + await db.saveAdminConfig(config); + setCachedConfig(config); + return { + username: byName.username, + role: byName.role, + sub, + }; + } + + if (!isOidcAutoRegister()) { + throw new OidcError( + 'oidc_not_registered', + '账号未开通,请联系管理员先创建用户' + ); + } + + const exists = await db.checkUserExist(mappedUsername); + if (!exists) { + const randomPassword = randomBytes(32).toString('hex'); + await db.registerUser(mappedUsername, randomPassword); + } + + users.push({ + username: mappedUsername, + role: 'user', + banned: false, + from: 'oidc', + oidcSub: sub, + }); + await db.saveAdminConfig(config); + setCachedConfig(config); + + return { + username: mappedUsername, + role: 'user', + sub, + }; +} + +export function sanitizeOidcErrorDetail(detail?: string): string | undefined { + if (!detail) { + return undefined; + } + const cleaned = detail.replace(/[\r\n\t]+/g, ' ').trim().slice(0, 180); + return cleaned || undefined; +} + +export function oidcLoginErrorPath( + code: string, + fallbackRedirect?: string, + detail?: string +): string { + const url = new URL('/login', 'http://local.invalid'); + url.searchParams.set('error', code); + const redirect = sanitizeRedirect(fallbackRedirect); + if (redirect !== '/') { + url.searchParams.set('redirect', redirect); + } + const safeDetail = sanitizeOidcErrorDetail(detail); + if (safeDetail) { + url.searchParams.set('error_detail', safeDetail); + } + return `${url.pathname}${url.search}`; +} diff --git a/src/lib/version.ts b/src/lib/version.ts index 94fa30c9c2..9fcd5f0647 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ -const CURRENT_VERSION = '100.1.3'; +const CURRENT_VERSION = '100.1.4'; // 导出当前版本号供其他地方使用 export { CURRENT_VERSION }; diff --git a/src/middleware.ts b/src/middleware.ts index b672d79247..7f44eb9573 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -125,6 +125,7 @@ function shouldSkipAuth(pathname: string): boolean { '/icons/', '/logo.png', '/screenshot.png', + '/api/oidc', ]; return skipPaths.some((path) => pathname.startsWith(path)); @@ -133,6 +134,6 @@ function shouldSkipAuth(pathname: string): boolean { // 配置middleware匹配规则 export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config).*)', + '/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/oidc|api/cron|api/server-config).*)', ], };