feat: SSO 统一认证 + Tailwind CSS + 主题切换下拉菜单 + 登录页统一

- 安装 openid-client v5,新增 OIDC 登录/回调端点
- 安装 Tailwind CSS v4,统一登录页样式
- ThemeToggle 支持浅色/深色/自动三种模式
- middleware 支持已登录用户访问登录页自动跳转首页
- 退出登录重定向到 Authelia end_session
- login/oidc 端点处理会话冲突响应
This commit is contained in:
aiyimickey 2026-06-29 18:39:22 +08:00
parent ae1e58a595
commit ec4bbcb71b
17 changed files with 1370 additions and 123 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ node_modules/
.next/ .next/
.env .env
.DS_Store .DS_Store
.env.local

View File

@ -46,5 +46,5 @@ npm run dev # http://localhost:6179
## 相关文档 ## 相关文档
- [OA 设计文档](../docs/OA-DESIGN.md) — 完整架构、认证流程、迁移步骤 - [OA 设计文档](../docs/design/OA-DESIGN.md) — 完整架构、认证流程、迁移步骤
- [OA UI 设计](../docs/OA-UI-DESIGN.md) — 页面设计、配色方案 - [OA UI 设计](../docs/OA-UI-DESIGN.md) — 页面设计、配色方案

972
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,13 +10,18 @@
"dependencies": { "dependencies": {
"ldapts": "^6.0.0", "ldapts": "^6.0.0",
"next": "^15.0.0", "next": "^15.0.0",
"resend": "^6.0.3", "openid-client": "^5.7.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"resend": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.1",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"autoprefixer": "^10.5.2",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.1",
"typescript": "^5.0.0" "typescript": "^5.0.0"
} }
} }

2
postcss.config.mjs Normal file
View File

@ -0,0 +1,2 @@
const config = { plugins: { '@tailwindcss/postcss': {} } }
export default config

View File

@ -0,0 +1,99 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { getOidcClient } from '@/lib/oidc'
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
// 从 OIDC_REDIRECT_URI 提取 base URL避免 request.url 使用 localhost
function getBaseUrl(): string {
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/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:6179/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. 验证 nonce
if (savedNonce && tokenSet.claims) {
const claims = tokenSet.claims()
if (claims.nonce !== savedNonce) {
return NextResponse.redirect(new URL('/login?error=nonce_mismatch', baseUrl))
}
}
// 7. 获取 userinfo
const userinfo = await client.userinfo(tokenSet.access_token!)
// 8. 使用 preferred_username 作为用户名sub 可能是 UUID
const username = (userinfo as any).preferred_username || userinfo.sub!
const displayName = userinfo.name || username
// 9. 签发 tlyq_session cookie
const sharedToken = signSharedJwt({ username: username as string, displayName: displayName as string })
const cfg = sharedCookieConfig()
const response = NextResponse.redirect(new URL('/', baseUrl))
response.cookies.set(cfg.name, sharedToken, cfg)
// 10. 存储 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: '/',
})
}
// 11. 清理 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

@ -3,6 +3,13 @@ import { cookies } from 'next/headers'
export async function POST() { export async function POST() {
const cookieStore = await cookies() const cookieStore = await cookies()
// 清除所有相关 cookie
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' }) cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
return NextResponse.redirect(new URL('/login', process.env.NEXT_PUBLIC_URL || 'http://localhost:6179')) cookieStore.set('session', '', { maxAge: 0, path: '/' })
cookieStore.set('oidc_id_token', '', { maxAge: 0, path: '/' })
// Authelia 4.38 不支持 end_session_endpoint直接跳转登录页
// Authelia session 会在 cookie 过期后自动清除
return NextResponse.redirect(new URL('/login', process.env.NEXT_PUBLIC_URL || 'http://127.0.0.1:6179'))
} }

17
src/app/globals.css Normal file
View File

@ -0,0 +1,17 @@
@import "tailwindcss";
@config "../../tailwind.config.js";
@layer base {
:root {
--bg: #f8fafc; --bg-card: #fff; --bg-hover: #f1f5f9; --border: #e2e8f0;
--text: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8;
}
.dark {
--bg: #020617; --bg-card: #0f172a; --bg-hover: #1e293b; --border: #1e293b;
--text: #f1f5f9; --text-secondary: #94a3b8; --text-muted: #64748b;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
}

View File

@ -1,3 +1,5 @@
import './globals.css'
export const metadata = { title: 'OA 统一门户' } export const metadata = { title: 'OA 统一门户' }
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
@ -13,24 +15,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
} catch(e) {} } catch(e) {}
})(); })();
`}} /> `}} />
<style>{`
:root {
--bg: #f8fafc; --bg-card: #fff; --bg-hover: #f1f5f9; --border: #e2e8f0;
--text: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8;
}
.dark {
--bg: #020617; --bg-card: #0f172a; --bg-hover: #1e293b; --border: #1e293b;
--text: #f1f5f9; --text-secondary: #94a3b8; --text-muted: #64748b;
}
`}</style>
</head> </head>
<body style={{ <body>
margin: 0,
background: 'var(--bg)',
color: 'var(--text)',
fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
transition: 'background 0.3s, color 0.3s',
}}>
{children} {children}
</body> </body>
</html> </html>

View File

@ -1,81 +1,87 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
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)
const searchParams = useSearchParams()
const isLocalMode = searchParams.get('method') === 'local'
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault(); setError(''); setLoading(true)
setError('')
setLoading(true)
try { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}) })
if (!res.ok) { const data = await res.json()
const d = await res.json() if (!res.ok) { setError(data.error || '登录失败'); return }
setError(d.error || '登录失败')
return
}
window.location.href = '/' window.location.href = '/'
} catch { } catch { setError('网络错误') } finally { setLoading(false) }
setError('网络错误')
} finally {
setLoading(false)
}
} }
const inputStyle: React.CSSProperties = { function handleSsoLogin() {
width: '100%', height: 42, padding: '0 12px', fetch('/api/auth/login/oidc').then(res => {
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8, if (res.status === 200) {
fontSize: 14, outline: 'none', boxSizing: 'border-box', color: 'var(--text)', return res.json().then(data => {
if (data.conflict) { window.location.href = '/' }
else { setError(data.error || '登录失败') }
})
} }
window.location.href = res.url || '/api/auth/login/oidc'
}).catch(() => { window.location.href = '/api/auth/login/oidc' })
}
const showLocalForm = isLocalMode || showLdapForm
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}> <div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
<div style={{ <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">
width: 400, background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, <h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white"></h1>
padding: '40px 36px', boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)', {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>}
}}>
<div style={{ textAlign: 'center', marginBottom: 32 }}> {!showLocalForm ? (
<div style={{ width: 44, height: 44, margin: '0 auto 14px', background: '#2563eb', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, color: '#fff' }}>&#9678;</div> <>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', margin: 0 }}></h1> <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">
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>TLYQ.IDENTITY</p>
</div> </button>
<form onSubmit={handleSubmit}> <p className="text-center text-xs text-slate-400 mb-3"> SSO </p>
<div style={{ marginBottom: 16 }}> <p className="text-center">
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}></div> <button onClick={() => setShowLdapForm(true)} className="text-xs text-slate-400 hover:text-slate-600 underline">
<input type="text" placeholder="LDAP 用户名" value={username} 使 LDAP
onChange={e => setUsername(e.target.value)}
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
style={inputStyle} />
</div>
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}></div>
<input type="password" placeholder="输入密码" value={password}
onChange={e => setPassword(e.target.value)}
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
style={inputStyle} />
</div>
{error && <p style={{ color: '#dc2626', fontSize: 13, marginBottom: 12, padding: '8px 12px', background: '#fef2f2', borderRadius: 8 }}>{error}</p>}
<button type="submit" disabled={loading} style={{
width: '100%', height: 44, background: loading ? '#60a5fa' : '#2563eb',
color: '#fff', border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 500, cursor: loading ? 'not-allowed' : 'pointer',
}}>
{loading ? '验证中...' : '登 录'}
</button> </button>
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 12, color: 'var(--text-muted)' }}>
<span style={{ color: '#2563eb', fontWeight: 500 }}>LLDAP</span>
</p> </p>
</>
) : (
<>
<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> </form>
{!isLocalMode && (
<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

@ -11,14 +11,14 @@ function siteUrl(url: string, domain: string): string {
} }
const CORE_SITES = [ const CORE_SITES = [
{ name: '资产管理', url: 'http://localhost:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' }, { name: '资产管理', url: 'http://127.0.0.1:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' },
{ name: '工单跟踪', url: 'http://localhost:6176', desc: '故障工单全流程管理SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' }, { name: '工单跟踪', url: 'http://127.0.0.1:6176', desc: '故障工单全流程管理SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' },
] ]
const OTHER_SITES = [ const OTHER_SITES = [
{ name: '官网', url: 'http://localhost:6173', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' }, { name: '官网', url: 'http://127.0.0.1:6173', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' },
{ name: '云平台', url: 'http://localhost:6174', desc: '云服务登录入口与资源概览', tag: 'CLOUD', dot: '#d97706', domain: 'cloud.tlyq.ai' }, { name: '云平台', url: 'http://127.0.0.1:6174', desc: '云服务登录入口与资源概览', tag: 'CLOUD', dot: '#d97706', domain: 'cloud.tlyq.ai' },
{ name: 'Token 工厂', url: 'http://localhost:6175', desc: 'Token 管理与发放平台', tag: 'TOKEN', dot: '#e11d48', domain: 'token.tlyq.ai' }, { name: 'Token 工厂', url: 'http://127.0.0.1:6175', desc: 'Token 管理与发放平台', tag: 'TOKEN', dot: '#e11d48', domain: 'token.tlyq.ai' },
{ name: '代码仓库', url: 'https://git.tlyq.ai', desc: 'Gitea 代码托管与版本管理', tag: 'GIT', dot: '#db2777', domain: 'git.tlyq.ai' }, { name: '代码仓库', url: 'https://git.tlyq.ai', desc: 'Gitea 代码托管与版本管理', tag: 'GIT', dot: '#db2777', domain: 'git.tlyq.ai' },
] ]

View File

@ -1,37 +1,77 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
type Theme = 'light' | 'dark' | 'auto'
export default function ThemeToggle() { export default function ThemeToggle() {
const [dark, setDark] = useState(true) const [theme, setTheme] = useState<Theme>('auto')
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
setDark(document.documentElement.classList.contains('dark')) const stored = localStorage.getItem('theme') as Theme | null
setTheme(stored || 'auto')
applyTheme(stored || 'auto')
}, []) }, [])
function toggle() { useEffect(() => {
const next = !dark const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
setDark(next) document.addEventListener('mousedown', handler)
document.documentElement.classList.toggle('dark', next) return () => document.removeEventListener('mousedown', handler)
localStorage.setItem('theme', next ? 'dark' : 'light') }, [])
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)
}
} }
function select(t: Theme) {
setTheme(t)
localStorage.setItem('theme', t)
applyTheme(t)
setOpen(false)
}
const icons: Record<Theme, string> = { light: '☀', dark: '☾', auto: '◐' }
const labels: Record<Theme, string> = { light: '浅色', dark: '深色', auto: '自动' }
return ( return (
<> <div ref={ref} style={{ position: 'relative' }}>
<button <button onClick={() => setOpen(!open)} title="切换主题" style={{
onClick={toggle}
className="theme-toggle-btn"
title={dark ? '切换到亮色模式' : '切换到暗色模式'}
style={{
width: 36, height: 36, borderRadius: 8, border: 'none', width: 36, height: 36, borderRadius: 8, border: 'none',
background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', background: open ? 'var(--bg-hover)' : 'transparent', color: 'var(--text-secondary)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, transition: 'background 0.15s', fontSize: 18, transition: 'background 0.15s',
}} }}>
> {icons[theme]}
{dark ? '☀' : '☾'}
</button> </button>
<style>{`.theme-toggle-btn:hover { background: var(--bg-hover) !important; }`}</style> {open && (
</> <div style={{
position: 'absolute', top: '100%', right: 0, marginTop: 4,
background: 'var(--bg-card)', border: '1px solid var(--border)',
borderRadius: 8, padding: '4px 0', minWidth: 100, zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}>
{(['light', 'dark', 'auto'] as Theme[]).map(t => (
<button key={t} onClick={() => select(t)} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '6px 12px', border: 'none', background: t === theme ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text)', cursor: 'pointer', fontSize: 13, textAlign: 'left',
}}>
<span>{icons[t]}</span>
<span>{labels[t]}</span>
{t === theme && <span style={{ marginLeft: 'auto', color: '#2563eb' }}></span>}
</button>
))}
</div>
)}
</div>
) )
} }

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 || 'oa-oidc'
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ''
const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI || 'https://oa.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

@ -24,8 +24,18 @@ function noCache(response: NextResponse) {
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
// 登录/设置密码/API 路径放行API 路由自行验证) // 登录页:已登录用户自动跳转首页
if (pathname === '/login' || pathname === '/setup-password' || pathname.startsWith('/api/auth/') || pathname.startsWith('/api/admin/')) { if (pathname === '/login') {
const token = request.cookies.get('tlyq_session')?.value
const payload = token ? decodeJwtPayload(token) : null
if (isValidPayload(payload)) {
return NextResponse.redirect(new URL('/', request.url))
}
return NextResponse.next()
}
// 设置密码/API 路径放行API 路由自行验证)
if (pathname === '/setup-password' || pathname.startsWith('/api/auth/') || pathname.startsWith('/api/admin/')) {
return NextResponse.next() return NextResponse.next()
} }
// /admin 管理页面需要认证 // /admin 管理页面需要认证

7
tailwind.config.js Normal file
View File

@ -0,0 +1,7 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: { extend: {} },
plugins: [],
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long