feat: SSO 统一认证 + ThemeProvider 三模式 + 登录页统一
- 安装 openid-client v5,新增 OIDC 登录/callback 端点 - ThemeProvider 支持 light/dark/auto 三种模式 - TopBar 主题切换改为下拉菜单 - middleware 登录页已登录自动跳转 - 退出登录重定向 Authelia end_session - 登录页添加 SSO 按钮 + LDAP 回退
This commit is contained in:
parent
67cba23ce3
commit
60e4694d76
|
|
@ -13,3 +13,4 @@ data/
|
||||||
db-backups/
|
db-backups/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.env.local
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,11 +22,30 @@ 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>}
|
||||||
|
|
||||||
|
{!showLdapForm ? (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
统一认证登录
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-xs text-slate-400 mb-3">通过 SSO 统一身份认证</p>
|
||||||
|
<p className="text-center">
|
||||||
|
<button onClick={() => setShowLdapForm(true)} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
||||||
|
使用 LDAP 直接登录
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
||||||
|
|
@ -40,6 +59,13 @@ export function LoginForm() {
|
||||||
</div>
|
</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>
|
<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>
|
||||||
|
<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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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="切换主题">
|
||||||
|
{icons[theme]}
|
||||||
</button>
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue