diff --git a/.gitignore b/.gitignore index 0d85273..03f4645 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ data/ db-backups/ .playwright-mcp/ *.tsbuildinfo +.env.local diff --git a/package-lock.json b/package-lock.json index 60e454d..065d5ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "ldapts": "^8.1.7", "lucide-react": "^1.8.0", "next": "^15.1.0", + "openid-client": "^5.7.1", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^3.8.1", @@ -2831,6 +2832,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3177,6 +3187,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/lucide-react": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.9.0.tgz", @@ -3369,6 +3391,24 @@ "node": ">=10" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3378,6 +3418,21 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4145,6 +4200,12 @@ "node": ">=0.8" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 4fb454c..3df68f0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "ldapts": "^8.1.7", "lucide-react": "^1.8.0", "next": "^15.1.0", + "openid-client": "^5.7.1", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^3.8.1", diff --git a/src/app/(auth)/login/LoginForm.tsx b/src/app/(auth)/login/LoginForm.tsx index 508a23d..7ba2489 100644 --- a/src/app/(auth)/login/LoginForm.tsx +++ b/src/app/(auth)/login/LoginForm.tsx @@ -6,6 +6,7 @@ export function LoginForm() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [showLdapForm, setShowLdapForm] = useState(false) async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(''); setLoading(true) @@ -13,7 +14,6 @@ export function LoginForm() { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }) const data = await res.json() if (!res.ok) { setError(data.error || '登录失败'); return } - // 直接从 URL 读取 redirect 参数,避免 Suspense/闭包导致的值捕获问题 const params = new URLSearchParams(window.location.search) const redirect = params.get('redirect') const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard' @@ -22,24 +22,50 @@ export function LoginForm() { finally { setLoading(false) } } + function handleSsoLogin() { + window.location.href = '/api/auth/login/oidc' + } + return (

资产管理系统

{error &&
{error}
} -
-
- - setUsername(e.target.value)} placeholder="请输入用户名" - className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required /> -
-
- - setPassword(e.target.value)} placeholder="请输入密码" - className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required /> -
- -
+ + {!showLdapForm ? ( + <> + +

通过 SSO 统一身份认证

+

+ +

+ + ) : ( + <> +
+
+ + setUsername(e.target.value)} placeholder="请输入用户名" + className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required /> +
+
+ + setPassword(e.target.value)} placeholder="请输入密码" + className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required /> +
+ +
+

+ +

+ + )}
) diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts new file mode 100644 index 0000000..cb7ec64 --- /dev/null +++ b/src/app/api/auth/callback/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { getOidcClient } from '@/lib/oidc' +import { signJwt } from '@/lib/auth' +import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' +import db from '@/lib/db' +import { ldapGetUserInfo } from '@/lib/ldap' + +function getBaseUrl(): string { + const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6177/api/auth/callback' + const url = new URL(redirectUri) + return `${url.protocol}//${url.host}` +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + const baseUrl = getBaseUrl() + + const cookieStore = await cookies() + + // 1. 错误处理 + if (error) { + return NextResponse.redirect(new URL(`/login?error=${error}`, baseUrl)) + } + + // 2. 验证 state + const savedState = cookieStore.get('oidc_state')?.value + if (!savedState || savedState !== state) { + return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl)) + } + + // 3. 取出 code_verifier + const codeVerifier = cookieStore.get('oidc_code_verifier')?.value + if (!codeVerifier) { + return NextResponse.redirect(new URL('/login?error=missing_verifier', baseUrl)) + } + + // 4. 验证 nonce + const savedNonce = cookieStore.get('oidc_nonce')?.value + + try { + // 5. 换取 token + const client = await getOidcClient() + const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6177/api/auth/callback' + const params = { code, state, iss: searchParams.get('iss') } + const checks = { + code_verifier: codeVerifier, + nonce: savedNonce, + state: savedState, + } + + const tokenSet = await client.callback(redirectUri, params, checks) + + // 6. 获取 userinfo + const userinfo = await client.userinfo(tokenSet.access_token!) + + // 7. 处理用户 + const username = (userinfo as any).preferred_username || userinfo.sub! + const displayName = userinfo.name || username + + // 查找本地用户 + let user = db.prepare( + 'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1' + ).get(username) as { id: number; username: string; role: string } | undefined + + if (!user) { + // 自动创建(viewer 角色) + const ldapInfo = await ldapGetUserInfo(username) + const ldapDisplayName = ldapInfo?.displayName || displayName + const email = ldapInfo?.email ?? null + db.prepare( + "INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))" + ).run(username, ldapDisplayName, email) + user = db.prepare( + 'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1' + ).get(username) as { id: number; username: string; role: string } + } + + // 更新登录时间 + db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(user!.id) + + // 8. 签发两个 cookie + const localToken = signJwt({ userId: user!.id, username: user!.username, role: user!.role }) + const sharedToken = signSharedJwt({ username, displayName }) + + const response = NextResponse.redirect(new URL('/', baseUrl)) + response.cookies.set('session_assets', localToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 86400, + path: '/', + }) + response.cookies.set(sharedCookieConfig().name, sharedToken, sharedCookieConfig()) + + // 9. 存储 id_token 用于登出 + if (tokenSet.id_token) { + response.cookies.set('oidc_id_token', tokenSet.id_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 86400, + path: '/', + }) + } + + // 10. 清理 OIDC 临时 cookie + response.cookies.delete('oidc_state') + response.cookies.delete('oidc_nonce') + response.cookies.delete('oidc_code_verifier') + + return response + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e) + console.error('OIDC callback error:', errorMsg) + return NextResponse.redirect(new URL(`/login?error=callback_error&detail=${encodeURIComponent(errorMsg)}`, baseUrl)) + } +} diff --git a/src/app/api/auth/login/oidc/route.ts b/src/app/api/auth/login/oidc/route.ts new file mode 100644 index 0000000..9171a98 --- /dev/null +++ b/src/app/api/auth/login/oidc/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { getOidcClient, generatePKCE, generateState, generateNonce } from '@/lib/oidc' +import { verifySharedJwt } from '@/lib/jwt' + +export async function GET(request: Request) { + const cookieStore = await cookies() + const existingSession = cookieStore.get('tlyq_session')?.value + const url = new URL(request.url) + const switchUser = url.searchParams.get('switch') === '1' + + // 检查是否已有登录用户 + if (existingSession && !switchUser) { + const existing = verifySharedJwt(existingSession) + if (existing) { + return NextResponse.json({ + conflict: true, + currentUser: existing.username, + displayName: existing.displayName, + }) + } + } + + // 预检 Authelia 健康状态 + const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' + try { + const healthRes = await fetch(`${autheliaUrl}/api/health`, { + signal: AbortSignal.timeout(5000), + }) + if (!healthRes.ok) { + return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可用' }, { status: 503 }) + } + } catch { + return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可达' }, { status: 503 }) + } + + // 生成 PKCE 参数 + const { codeVerifier, codeChallenge } = generatePKCE() + const state = generateState() + const nonce = generateNonce() + + // 构建授权 URL + const client = await getOidcClient() + const authorizationUrl = client.authorizationUrl({ + scope: 'openid profile email', + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + ...(switchUser && { prompt: 'login' }), + }) + + // 存储到 httpOnly cookie(5 分钟过期) + const response = NextResponse.redirect(authorizationUrl) + + response.cookies.set('oidc_code_verifier', codeVerifier, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 300, + path: '/', + }) + + response.cookies.set('oidc_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 300, + path: '/', + }) + + response.cookies.set('oidc_nonce', nonce, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 300, + path: '/', + }) + + return response +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index f208145..b3cba4c 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -8,6 +8,7 @@ export async function POST(request: Request) { const cookieStore = await cookies() cookieStore.set('session_assets', '', { maxAge: 0, path: '/' }) cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' }) + cookieStore.set('oidc_id_token', '', { maxAge: 0, path: '/' }) if (session) { writeAuditLog({ diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index e6e5978..c358611 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -2,24 +2,53 @@ import { useRouter } from 'next/navigation' import { useTheme } from '@/components/providers/ThemeProvider' import { Sun, Moon, LogOut, User } from 'lucide-react' +import { useState, useRef, useEffect } from 'react' + +type Theme = 'light' | 'dark' | 'auto' interface TopBarProps { user?: { display_name: string; role: string } } export default function TopBar({ user }: TopBarProps) { const router = useRouter() - const { theme, toggleTheme } = useTheme() + const { theme, setTheme } = useTheme() + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, []) + async function handleLogout() { await fetch('/api/auth/logout', { method: 'POST' }) - // 清除所有 cookies 后跳转登录页,下次请求将触发 SSO 重新认证 router.push('/login'); router.refresh() } + + const icons: Record = { light: , dark: , auto: } + const labels: Record = { light: '浅色', dark: '深色', auto: '自动' } + return (
- +
+ + {open && ( +
+ {(['light', 'dark', 'auto'] as Theme[]).map(t => ( + + ))} +
+ )} +
{user && (
diff --git a/src/components/providers/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx index aa8fdf9..88704c4 100644 --- a/src/components/providers/ThemeProvider.tsx +++ b/src/components/providers/ThemeProvider.tsx @@ -1,33 +1,38 @@ 'use client' import { createContext, useContext, useEffect, useState, ReactNode } from 'react' -type Theme = 'light' | 'dark' -interface ThemeContextType { theme: Theme; toggleTheme: () => void } -const ThemeContext = createContext({ theme: 'dark', toggleTheme: () => {} }) +type Theme = 'light' | 'dark' | 'auto' +interface ThemeContextType { theme: Theme; setTheme: (t: Theme) => void } +const ThemeContext = createContext({ theme: 'auto', setTheme: () => {} }) export function useTheme() { return useContext(ThemeContext) } +function applyTheme(t: Theme) { + const root = document.documentElement + root.classList.remove('light', 'dark') + if (t === 'auto') { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + root.classList.add(prefersDark ? 'dark' : 'light') + } else { + root.classList.add(t) + } +} + export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setTheme] = useState('dark') + const [theme, setThemeState] = useState('auto') useEffect(() => { - // Read initial theme from localStorage or system preference const stored = localStorage.getItem('theme') as Theme | null - const initial = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') - setTheme(initial) - document.documentElement.classList.toggle('dark', initial === 'dark') - document.documentElement.classList.toggle('light', initial === 'light') + const initial = stored || 'auto' + setThemeState(initial) + applyTheme(initial) }, []) - const toggleTheme = () => { - setTheme(prev => { - const next = prev === 'dark' ? 'light' : 'dark' - localStorage.setItem('theme', next) - document.documentElement.classList.toggle('dark', next === 'dark') - document.documentElement.classList.toggle('light', next === 'light') - return next - }) + const setTheme = (t: Theme) => { + setThemeState(t) + localStorage.setItem('theme', t) + applyTheme(t) } - return {children} + return {children} } diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts new file mode 100644 index 0000000..f0390a7 --- /dev/null +++ b/src/lib/oidc.ts @@ -0,0 +1,47 @@ +import { Issuer } from 'openid-client' +import crypto from 'crypto' + +const AUTHELIA_URL = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' +const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || 'assets-oidc' +const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || '' +const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI || 'https://assets.tlyq.ai/api/auth/callback' + +let oidcClient: any = null +let lastDiscovery = 0 +const DISCOVERY_TTL = 3600000 // 1 小时 + +export async function getOidcClient() { + const now = Date.now() + if (oidcClient && (now - lastDiscovery) < DISCOVERY_TTL) { + return oidcClient + } + + try { + const issuer = await Issuer.discover(AUTHELIA_URL) + oidcClient = new issuer.Client({ + client_id: OIDC_CLIENT_ID, + client_secret: OIDC_CLIENT_SECRET, + redirect_uris: [OIDC_REDIRECT_URI], + response_types: ['code'], + }) + lastDiscovery = now + return oidcClient + } catch (error) { + console.error('OIDC discovery 失败:', error) + throw error + } +} + +export function generatePKCE() { + const codeVerifier = crypto.randomBytes(32).toString('base64url') + const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url') + return { codeVerifier, codeChallenge } +} + +export function generateState() { + return crypto.randomBytes(32).toString('base64url') +} + +export function generateNonce() { + return crypto.randomBytes(32).toString('base64url') +} diff --git a/src/middleware.ts b/src/middleware.ts index c5802f6..c3327ec 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -29,8 +29,18 @@ function isValidPayload(payload: Record | null): boolean { export function middleware(request: NextRequest) { const { pathname } = request.nextUrl - // 登录/退出路径 + 内部 API 放行(自有 key 认证) - if (pathname === '/login' || pathname.startsWith('/api/auth/login') || pathname === '/api/auth/logout' || pathname.startsWith('/api/internal/')) { + // 登录页:已登录用户自动跳转首页 + if (pathname === '/login' || pathname.startsWith('/login')) { + const token = request.cookies.get('tlyq_session')?.value || request.cookies.get('session_assets')?.value + const payload = token ? decodeJwtPayload(token) : null + if (isValidPayload(payload)) { + return NextResponse.redirect(new URL('/dashboard', request.url)) + } + return NextResponse.next() + } + + // 退出路径 + 内部 API 放行(自有 key 认证) + if (pathname.startsWith('/api/auth/login') || pathname.startsWith('/api/auth/callback') || pathname === '/api/auth/logout' || pathname.startsWith('/api/internal/')) { return NextResponse.next() }