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}
}
-
+
+ {!showLdapForm ? (
+ <>
+
+
通过 SSO 统一身份认证
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
)
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()
}