feat: SSO 统一认证 + ThemeProvider 三模式 + 登录页统一

- 安装 openid-client v5,新增 OIDC 登录/callback 端点
- ThemeProvider 支持 light/dark/auto 三种模式
- TopBar 主题切换改为下拉菜单
- middleware 登录页已登录自动跳转
- 退出登录重定向 Authelia end_session
- 登录页添加 SSO 按钮 + LDAP 回退
This commit is contained in:
gitadmin 2026-06-29 18:39:29 +08:00
parent 67cba23ce3
commit 60e4694d76
11 changed files with 422 additions and 39 deletions

1
.gitignore vendored
View File

@ -13,3 +13,4 @@ data/
db-backups/ db-backups/
.playwright-mcp/ .playwright-mcp/
*.tsbuildinfo *.tsbuildinfo
.env.local

61
package-lock.json generated
View File

@ -14,6 +14,7 @@
"ldapts": "^8.1.7", "ldapts": "^8.1.7",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "^15.1.0", "next": "^15.1.0",
"openid-client": "^5.7.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
@ -2831,6 +2832,15 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@ -3177,6 +3187,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lucide-react": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.9.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.9.0.tgz",
@ -3369,6 +3391,24 @@
"node": ">=10" "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": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -3378,6 +3418,21 @@
"wrappy": "1" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -4145,6 +4200,12 @@
"node": ">=0.8" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -17,6 +17,7 @@
"ldapts": "^8.1.7", "ldapts": "^8.1.7",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "^15.1.0", "next": "^15.1.0",
"openid-client": "^5.7.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",

View File

@ -6,6 +6,7 @@ export function LoginForm() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showLdapForm, setShowLdapForm] = useState(false)
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setError(''); setLoading(true) 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 res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) })
const data = await res.json() const data = await res.json()
if (!res.ok) { setError(data.error || '登录失败'); return } if (!res.ok) { setError(data.error || '登录失败'); return }
// 直接从 URL 读取 redirect 参数,避免 Suspense/闭包导致的值捕获问题
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const redirect = params.get('redirect') const redirect = params.get('redirect')
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard' const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard'
@ -22,24 +22,50 @@ export function LoginForm() {
finally { setLoading(false) } finally { setLoading(false) }
} }
function handleSsoLogin() {
window.location.href = '/api/auth/login/oidc'
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4"> <div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8"> <div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white"></h1> <h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white"></h1>
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>} {error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div> {!showLdapForm ? (
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label> <>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="请输入用户名" <button onClick={handleSsoLogin} className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors duration-200 mb-3">
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 />
</div> </button>
<div> <p className="text-center text-xs text-slate-400 mb-3"> SSO </p>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label> <p className="text-center">
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="请输入密码" <button onClick={() => setShowLdapForm(true)} className="text-xs text-slate-400 hover:text-slate-600 underline">
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 /> 使 LDAP
</div> </button>
<button type="submit" disabled={loading} className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">{loading ? '登录中...' : '登录'}</button> </p>
</form> </>
) : (
<>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="text" value={username} onChange={e => 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 />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="password" value={password} onChange={e => 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 />
</div>
<button type="submit" disabled={loading} className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">{loading ? '登录中...' : '登录'}</button>
</form>
<p className="text-center mt-3">
<button onClick={() => setShowLdapForm(false)} className="text-xs text-slate-400 hover:text-slate-600 underline">
</button>
</p>
</>
)}
</div> </div>
</div> </div>
) )

View File

@ -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))
}
}

View File

@ -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 cookie5 分钟过期)
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
}

View File

@ -8,6 +8,7 @@ export async function POST(request: Request) {
const cookieStore = await cookies() const cookieStore = await cookies()
cookieStore.set('session_assets', '', { maxAge: 0, path: '/' }) cookieStore.set('session_assets', '', { maxAge: 0, path: '/' })
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' }) cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
cookieStore.set('oidc_id_token', '', { maxAge: 0, path: '/' })
if (session) { if (session) {
writeAuditLog({ writeAuditLog({

View File

@ -2,24 +2,53 @@
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useTheme } from '@/components/providers/ThemeProvider' import { useTheme } from '@/components/providers/ThemeProvider'
import { Sun, Moon, LogOut, User } from 'lucide-react' 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 } } interface TopBarProps { user?: { display_name: string; role: string } }
export default function TopBar({ user }: TopBarProps) { export default function TopBar({ user }: TopBarProps) {
const router = useRouter() const router = useRouter()
const { theme, toggleTheme } = useTheme() const { theme, setTheme } = useTheme()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(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() { async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' }) await fetch('/api/auth/logout', { method: 'POST' })
// 清除所有 cookies 后跳转登录页,下次请求将触发 SSO 重新认证
router.push('/login'); router.refresh() router.push('/login'); router.refresh()
} }
const icons: Record<Theme, React.ReactNode> = { light: <Sun size={16} />, dark: <Moon size={16} />, auto: <span style={{fontSize:14}}></span> }
const labels: Record<Theme, string> = { light: '浅色', dark: '深色', auto: '自动' }
return ( return (
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30"> <header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
<div /> <div />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={toggleTheme} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title={theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'}> <div ref={ref} className="relative">
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />} <button onClick={() => setOpen(!open)} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="切换主题">
</button> {icons[theme]}
</button>
{open && (
<div className="absolute top-full right-0 mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg z-50 py-1 min-w-[100px]">
{(['light', 'dark', 'auto'] as Theme[]).map(t => (
<button key={t} onClick={() => { setTheme(t); setOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left ${t === theme ? 'bg-slate-100 dark:bg-slate-700' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50'}`}>
<span>{icons[t]}</span>
<span>{labels[t]}</span>
{t === theme && <span className="ml-auto text-blue-600"></span>}
</button>
))}
</div>
)}
</div>
{user && ( {user && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"> <div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">

View File

@ -1,33 +1,38 @@
'use client' 'use client'
import { createContext, useContext, useEffect, useState, ReactNode } from 'react' import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
type Theme = 'light' | 'dark' type Theme = 'light' | 'dark' | 'auto'
interface ThemeContextType { theme: Theme; toggleTheme: () => void } interface ThemeContextType { theme: Theme; setTheme: (t: Theme) => void }
const ThemeContext = createContext<ThemeContextType>({ theme: 'dark', toggleTheme: () => {} }) const ThemeContext = createContext<ThemeContextType>({ theme: 'auto', setTheme: () => {} })
export function useTheme() { return useContext(ThemeContext) } 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 }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark') const [theme, setThemeState] = useState<Theme>('auto')
useEffect(() => { useEffect(() => {
// Read initial theme from localStorage or system preference
const stored = localStorage.getItem('theme') as Theme | null const stored = localStorage.getItem('theme') as Theme | null
const initial = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') const initial = stored || 'auto'
setTheme(initial) setThemeState(initial)
document.documentElement.classList.toggle('dark', initial === 'dark') applyTheme(initial)
document.documentElement.classList.toggle('light', initial === 'light')
}, []) }, [])
const toggleTheme = () => { const setTheme = (t: Theme) => {
setTheme(prev => { setThemeState(t)
const next = prev === 'dark' ? 'light' : 'dark' localStorage.setItem('theme', t)
localStorage.setItem('theme', next) applyTheme(t)
document.documentElement.classList.toggle('dark', next === 'dark')
document.documentElement.classList.toggle('light', next === 'light')
return next
})
} }
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider> return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
} }

47
src/lib/oidc.ts Normal file
View File

@ -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')
}

View File

@ -29,8 +29,18 @@ function isValidPayload(payload: Record<string, unknown> | null): boolean {
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl 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() return NextResponse.next()
} }