feat: SSO双路径认证 + 端口修正 5177→6177

- 中间件支持 X-Remote-User (SSO) + JWT 双路径
- lib/auth.ts 新增 getSession() 统一会话获取
- 所有 API 路由改用 getSession(),支持 SSO header 回退
- 退出登录同时清除 SSO cookie
- 侧边栏根据角色显示/隐藏系统设置
- layout.tsx 支持 SSO 用户自动创建
- package.json 端口 5177→6177
- 跨站点引用 localhost 端口全部修正
This commit is contained in:
gitadmin 2026-05-09 17:14:36 +08:00
parent 3e7f94b014
commit c6a92ed33a
27 changed files with 176 additions and 119 deletions

View File

@ -4,6 +4,6 @@ NODE_ENV=development
# issue-ai API 配置(用于故障历史功能) # issue-ai API 配置(用于故障历史功能)
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS云上必须通过 deploy-ai.sh 设置 # NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS云上必须通过 deploy-ai.sh 设置
# 本地开发http://localhost:5176/tickets # 本地开发http://localhost:6176/tickets
# 云上生产https://issue.tlyq.ai/tickets # 云上生产https://issue.tlyq.ai/tickets
NEXT_PUBLIC_ISSUE_URL=http://localhost:5176/tickets NEXT_PUBLIC_ISSUE_URL=http://localhost:6176/tickets

View File

@ -13,7 +13,7 @@ assets-ai 是基于 Next.js + SQLite 的 IT 设备资产管理系统CMDB
| 站点域名 | `assets.tlyq.ai` | | 站点域名 | `assets.tlyq.ai` |
| 服务器 | txjpIP: 43.133.38.210 | | 服务器 | txjpIP: 43.133.38.210 |
| 代码路径 | `/root/docker/assets-ai/` | | 代码路径 | `/root/docker/assets-ai/` |
| 本地端口 | 5177 | | 本地端口 | 6177 |
| 容器名 | `assets-ai` | | 容器名 | `assets-ai` |
| 数据库 | SQLite`data/assets.db` | | 数据库 | SQLite`data/assets.db` |
| 默认账号 | `admin` / `admin123` | | 默认账号 | `admin` / `admin123` |
@ -140,9 +140,9 @@ npm run import # 导入设备数据
| 环境变量 | 本地开发 | 云服务器txjp | 说明 | | 环境变量 | 本地开发 | 云服务器txjp | 说明 |
|---------|---------|----------------|------| |---------|---------|----------------|------|
| `ISSUE_API_URL` | `http://localhost:5176/api` | `http://issue-ai:3000/api` | 调用 issue API 地址 | | `ISSUE_API_URL` | `http://localhost:6176/api` | `http://issue-ai:3000/api` | 调用 issue API 地址 |
| `ISSUE_API_KEY` | 本地 issue-ai 生成 | 云上 issue-ai 生成 | **每个环境独立,不可跨环境使用** | | `ISSUE_API_KEY` | 本地 issue-ai 生成 | 云上 issue-ai 生成 | **每个环境独立,不可跨环境使用** |
| `NEXT_PUBLIC_ISSUE_URL` | `http://localhost:5176/tickets` | `https://issue.tlyq.ai` | 前端跳转链接(构建时内嵌) | | `NEXT_PUBLIC_ISSUE_URL` | `http://localhost:6176/tickets` | `https://issue.tlyq.ai` | 前端跳转链接(构建时内嵌) |
| `ALLOWED_API_KEYS` | issue-ai 调用本系统时需要的 Key | 云上 issue-ai 生成的 Key | 仅 issue→assets 方向需要 | | `ALLOWED_API_KEYS` | issue-ai 调用本系统时需要的 Key | 云上 issue-ai 生成的 Key | 仅 issue→assets 方向需要 |
| `JWT_SECRET` | `dev-secret-key-local` | `${ASSETS_JWT_SECRET}` | 生产必须强密钥 | | `JWT_SECRET` | `dev-secret-key-local` | `${ASSETS_JWT_SECRET}` | 生产必须强密钥 |
| `DATABASE_PATH` | `./data/assets.db` | `/app/data/assets.db` | Docker volume 挂载 | | `DATABASE_PATH` | `./data/assets.db` | `/app/data/assets.db` | Docker volume 挂载 |
@ -154,9 +154,9 @@ npm run import # 导入设备数据
DATABASE_PATH=./data/assets.db DATABASE_PATH=./data/assets.db
JWT_SECRET=dev-secret-key-local JWT_SECRET=dev-secret-key-local
NODE_ENV=development NODE_ENV=development
ISSUE_API_URL=http://localhost:5176/api ISSUE_API_URL=http://localhost:6176/api
ISSUE_API_KEY=ak_<32字节十六进制> ISSUE_API_KEY=ak_<32字节十六进制>
NEXT_PUBLIC_ISSUE_URL=http://localhost:5176/tickets NEXT_PUBLIC_ISSUE_URL=http://localhost:6176/tickets
``` ```
--- ---

View File

@ -3,7 +3,7 @@ services:
build: . build: .
container_name: assets-ai container_name: assets-ai
ports: ports:
- "5177:3000" - "6177:3000"
volumes: volumes:
- assets-data:/app/data - assets-data:/app/data
- assets-uploads:/app/uploads - assets-uploads:/app/uploads

View File

@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5177", "dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 6177",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",

View File

@ -12,11 +12,34 @@ export default async function AppLayout({ children }: { children: React.ReactNod
const originalPath = headersList.get('x-original-pathname') || '' const originalPath = headersList.get('x-original-pathname') || ''
const loginUrl = '/login' + (originalPath ? `?redirect=${encodeURIComponent(originalPath)}` : '') const loginUrl = '/login' + (originalPath ? `?redirect=${encodeURIComponent(originalPath)}` : '')
// 路径 1SSO来自 nginx auth_request 代理认证)
let ssoUsername = ''
const ssoSession = cookieStore.get('session')?.value
if (ssoSession) {
try { ssoUsername = JSON.parse(ssoSession).username || '' } catch { }
}
if (!ssoUsername) ssoUsername = headersList.get('x-remote-user') || ''
if (ssoUsername) {
db.prepare(
"INSERT OR IGNORE INTO users (username, password_hash, display_name, role, is_active) VALUES (?, '__SSO__', ?, 'viewer', 1)"
).run(ssoUsername, ssoUsername)
const user = db.prepare(
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
).get(ssoUsername) as { id: number; username: string; display_name: string; role: string } | undefined
if (user) {
return <AppShell user={{ display_name: user.display_name, role: user.role }}>{children}</AppShell>
}
}
// 路径 2JWT cookie本地开发 / @fallback 紧急绕过)
const token = cookieStore.get('session_assets')?.value const token = cookieStore.get('session_assets')?.value
if (!token) redirect(loginUrl) if (!token) redirect(loginUrl)
const payload = verifyJwt(token) const payload = verifyJwt(token)
if (!payload) redirect(loginUrl) if (!payload) redirect(loginUrl)
const user = db.prepare('SELECT display_name, role FROM users WHERE id = ? AND is_active = 1').get(payload.userId) as { display_name: string; role: string } | undefined const user = db.prepare(
'SELECT display_name, role FROM users WHERE id = ? AND is_active = 1'
).get(payload.userId) as { display_name: string; role: string } | undefined
if (!user) redirect(loginUrl) if (!user) redirect(loginUrl)
return <AppShell user={user}>{children}</AppShell> return <AppShell user={user}>{children}</AppShell>
} }

View File

@ -1,15 +1,10 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession() const session = await getSession()

View File

@ -1,15 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt, generateApiKey, hashApiKey } from '@/lib/auth' import { getSession, generateApiKey, hashApiKey } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function GET() { export async function GET() {
const session = await getSession() const session = await getSession()

View File

@ -1,15 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt, verifyApiKey } from '@/lib/auth' import { getSession, verifyApiKey } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
function getApiKeyAuth(request: Request) { function getApiKeyAuth(request: Request) {
const auth = request.headers.get('Authorization') || '' const auth = request.headers.get('Authorization') || ''

View File

@ -1,15 +1,10 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
const UPDATABLE_FIELDS = [ const UPDATABLE_FIELDS = [
'device_type', 'device_purpose', 'room', 'rack_position', 'status', 'device_type', 'device_purpose', 'room', 'rack_position', 'status',

View File

@ -1,7 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { exportAssetsToBuffer } from '@/lib/excel' import { exportAssetsToBuffer } from '@/lib/excel'
@ -30,7 +30,7 @@ export async function GET(request: Request) {
const cookieStore = await cookies() const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value const token = cookieStore.get('session_assets')?.value
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const payload = verifyJwt(token) const payload = await getSession()
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
if (!payload.role) return NextResponse.json({ error: '会话数据异常,请重新登录' }, { status: 401 }) if (!payload.role) return NextResponse.json({ error: '会话数据异常,请重新登录' }, { status: 401 })

View File

@ -1,7 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
const ALLOWED_FIELDS = new Set([ const ALLOWED_FIELDS = new Set([
'device_type', 'device_purpose', 'room', 'rack_position', 'node_name', 'device_type', 'device_purpose', 'room', 'rack_position', 'node_name',
@ -24,7 +24,7 @@ export async function GET(request: Request) {
const cookieStore = await cookies() const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value const token = cookieStore.get('session_assets')?.value
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const payload = verifyJwt(token) const payload = await getSession()
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)

View File

@ -1,16 +1,11 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { parseImportBuffer } from '@/lib/excel' import { parseImportBuffer } from '@/lib/excel'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await getSession() const session = await getSession()

View File

@ -1,15 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt, verifyApiKey } from '@/lib/auth' import { getSession, verifyApiKey } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
function getApiKeyAuth(request: Request) { function getApiKeyAuth(request: Request) {
const auth = request.headers.get('Authorization') || '' const auth = request.headers.get('Authorization') || ''

View File

@ -1,13 +1,13 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { generateTemplateBuffer } from '@/lib/excel' import { generateTemplateBuffer } from '@/lib/excel'
export async function GET() { export async function GET() {
const cookieStore = await cookies() const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value const token = cookieStore.get('session_assets')?.value
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const payload = verifyJwt(token) const payload = await getSession()
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
const buffer = generateTemplateBuffer() const buffer = generateTemplateBuffer()

View File

@ -1,7 +1,12 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
export async function POST() { export async function POST() {
const cookieStore = await cookies() const cookieStore = await cookies()
cookieStore.set('session_assets', '', { maxAge: 0, path: '/' }) cookieStore.set('session_assets', '', { maxAge: 0, path: '/' })
cookieStore.set('session', '', { maxAge: 0, path: '/' })
// 清除 Authelia SSO cookiedomain 匹配才会生效)
cookieStore.set('authelia_session', '', { maxAge: 0, path: '/', domain: '127.0.0.1' })
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} }

View File

@ -1,19 +1,58 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies, headers } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession, verifyJwt, signJwt } from '@/lib/auth'
export async function GET() { export async function GET() {
try { try {
const cookieStore = await cookies() const cookieStore = await cookies()
// 路径 1SSO来自 nginx auth_request 代理)
// 优先读 cookie若没有则直接读 header首次请求时 cookie 可能尚未写入)
let ssoUsername = ''
try {
const ssoSession = cookieStore.get('session')?.value
if (ssoSession) ssoUsername = JSON.parse(ssoSession).username || ''
} catch { }
if (!ssoUsername) {
const headersList = await headers()
ssoUsername = headersList.get('x-remote-user') || ''
}
if (ssoUsername) {
db.prepare(
"INSERT OR IGNORE INTO users (username, password_hash, display_name, role, is_active) VALUES (?, '__SSO__', ?, ?, 1)"
).run(ssoUsername, ssoUsername, 'viewer')
const user = db.prepare(
'SELECT id, username, display_name, email, role FROM users WHERE username = ? AND is_active = 1'
).get(ssoUsername) as Record<string, unknown> | undefined
if (user) {
const roleRow = db.prepare(
'SELECT permissions FROM roles WHERE name = ?'
).get(user.role) as { permissions: string } | undefined
const permissions: string[] = roleRow ? JSON.parse(roleRow.permissions) : []
const jwt = signJwt({ userId: user.id as number, username: user.username as string, role: user.role as string })
const response = NextResponse.json({ user: { ...user, permissions } })
response.cookies.set('session_assets', jwt, { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 86400 })
return response
}
}
// 路径 2JWT cookie本地开发 / @fallback 紧急绕过)
const token = cookieStore.get('session_assets')?.value const token = cookieStore.get('session_assets')?.value
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const payload = verifyJwt(token) const payload = verifyJwt(token)
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
const user = db.prepare('SELECT id, username, display_name, email, role FROM users WHERE id = ? AND is_active = 1').get(payload.userId) as Record<string, unknown> | undefined const user = db.prepare(
'SELECT id, username, display_name, email, role FROM users WHERE id = ? AND is_active = 1'
).get(payload.userId) as Record<string, unknown> | undefined
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 }) if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 })
const roleRow = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined const roleRow = db.prepare(
'SELECT permissions FROM roles WHERE name = ?'
).get(user.role) as { permissions: string } | undefined
const permissions: string[] = roleRow ? JSON.parse(roleRow.permissions) : [] const permissions: string[] = roleRow ? JSON.parse(roleRow.permissions) : []
return NextResponse.json({ user: { ...user, permissions } }) return NextResponse.json({ user: { ...user, permissions } })
} catch { return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 }) } } catch {
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
}
} }

View File

@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
@ -12,11 +11,8 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
initDatabase() initDatabase()
const cookieStore = await cookies() const session = await getSession()
const token = cookieStore.get('session_assets')?.value if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const session = verifyJwt(token)
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params const { id } = await params
@ -45,11 +41,8 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
initDatabase() initDatabase()
const cookieStore = await cookies() const session = await getSession()
const token = cookieStore.get('session_assets')?.value if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const session = verifyJwt(token)
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params const { id } = await params

View File

@ -1,17 +1,13 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
export async function GET() { export async function GET() {
initDatabase() initDatabase()
const cookieStore = await cookies() const session = await getSession()
const token = cookieStore.get('session_assets')?.value if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const session = verifyJwt(token)
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
const roles = db.prepare('SELECT * FROM roles ORDER BY id').all() const roles = db.prepare('SELECT * FROM roles ORDER BY id').all()
return NextResponse.json({ roles }) return NextResponse.json({ roles })
@ -19,11 +15,8 @@ export async function GET() {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
initDatabase() initDatabase()
const cookieStore = await cookies() const session = await getSession()
const token = cookieStore.get('session_assets')?.value if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const session = verifyJwt(token)
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json() const body = await request.json()

View File

@ -1,14 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function GET() { export async function GET() {
const session = await getSession() const session = await getSession()

View File

@ -1,15 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt, hashPassword } from '@/lib/auth' import { getSession, hashPassword } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession() const session = await getSession()

View File

@ -1,15 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt, hashPassword } from '@/lib/auth' import { getSession, hashPassword } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function GET() { export async function GET() {
const session = await getSession() const session = await getSession()

View File

@ -9,7 +9,7 @@ interface AppShellProps { children: ReactNode; user?: { display_name: string; ro
export default function AppShell({ children, user }: AppShellProps) { export default function AppShell({ children, user }: AppShellProps) {
return ( return (
<ThemeProvider> <ThemeProvider>
<Sidebar /> <Sidebar role={user?.role} />
<TopBar user={user} /> <TopBar user={user} />
<main className="ml-60 pt-14 min-h-screen bg-slate-50 dark:bg-slate-950"> <main className="ml-60 pt-14 min-h-screen bg-slate-50 dark:bg-slate-950">
<div className="p-6">{children}</div> <div className="p-6">{children}</div>

View File

@ -15,8 +15,9 @@ const settingsItems = [
{ href: '/settings/api-keys', label: 'API Key', icon: Key }, { href: '/settings/api-keys', label: 'API Key', icon: Key },
] ]
export default function Sidebar() { export default function Sidebar({ role }: { role?: string }) {
const pathname = usePathname() const pathname = usePathname()
const isAdmin = role === 'admin'
return ( return (
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40"> <aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800"> <div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
@ -28,6 +29,7 @@ export default function Sidebar() {
const Icon = item.icon const Icon = item.icon
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>) return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
})} })}
{isAdmin && (
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3"> <div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
<div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider"> <div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
<Settings size={14} /> <Settings size={14} />
@ -38,6 +40,7 @@ export default function Sidebar() {
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>) return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
})} })}
</div> </div>
)}
</nav> </nav>
</aside> </aside>
) )

View File

@ -10,6 +10,7 @@ export default function TopBar({ user }: TopBarProps) {
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
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()
} }
return ( return (

View File

@ -37,6 +37,28 @@ export function hashApiKey(key: string): string { return crypto.createHash('sha2
export function generateApiKey(): string { return `ak_${crypto.randomBytes(32).toString('hex')}` } export function generateApiKey(): string { return `ak_${crypto.randomBytes(32).toString('hex')}` }
export function verifySession(token: string): SessionPayload | null { return verifyJwt(token) } export function verifySession(token: string): SessionPayload | null { return verifyJwt(token) }
// 统一获取当前会话:先查 JWT再查 SSO header解决首次加载时 JWT 尚未签发的竞态问题)
import { cookies, headers } from 'next/headers'
export async function getSession(): Promise<SessionPayload | null> {
const cookieStore = await cookies()
// 1. JWT
const token = cookieStore.get('session_assets')?.value
if (token) {
const payload = verifyJwt(token)
if (payload) return payload
}
// 2. SSO headernginx auth_request 注入,首次请求时 JWT 可能尚未签发)
const headersList = await headers()
const ssoUsername = headersList.get('x-remote-user')
if (ssoUsername) {
const user = db.prepare(
'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1'
).get(ssoUsername) as SessionPayload | undefined
if (user) return user
}
return null
}
export function verifyApiKey(key: string): { id: number; name: string; permissions: string[] } | null { export function verifyApiKey(key: string): { id: number; name: string; permissions: string[] } | null {
if (!key.startsWith('ak_')) return null if (!key.startsWith('ak_')) return null
const keyHash = hashApiKey(key) const keyHash = hashApiKey(key)

View File

@ -3,11 +3,11 @@
* issue-ai API * issue-ai API
* *
* *
* ISSUE_API_URL issue-ai API http://localhost:5176/api云上必须设 http://issue-ai:3000/api * ISSUE_API_URL issue-ai API http://localhost:6176/api云上必须设 http://issue-ai:3000/api
* ISSUE_API_KEY API Cookie * ISSUE_API_KEY API Cookie
*/ */
const API_BASE = process.env.ISSUE_API_URL || 'http://localhost:5176/api' const API_BASE = process.env.ISSUE_API_URL || 'http://localhost:6176/api'
const API_KEY = process.env.ISSUE_API_KEY || '' const API_KEY = process.env.ISSUE_API_KEY || ''
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -13,6 +13,37 @@ function decodeJwtPayload(token: string): Record<string, unknown> | null {
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
// 清除外部注入的 trust proxy headers防伪造
const requestHeaders = new Headers(request.headers)
requestHeaders.delete('x-remote-user')
requestHeaders.delete('x-remote-groups')
// SSO 代理认证路径:检测 X-Remote-User header + 代理密钥验证
const remoteUser = request.headers.get('x-remote-user')
const proxyKey = request.headers.get('x-auth-proxy-key')
const isFromNginx = proxyKey === 'internal-auth-key-tlyq-2026'
if (remoteUser && isFromNginx) {
// logout 路径不设置 SSO session避免清除后又重新设置
if (pathname === '/api/auth/logout') return NextResponse.next()
const response = pathname === '/login' || pathname.startsWith('/api/auth/login')
? NextResponse.redirect(new URL('/', request.url))
: NextResponse.next()
response.cookies.set('session', JSON.stringify({ username: remoteUser }), {
httpOnly: true,
sameSite: 'lax',
path: '/',
})
if (pathname.startsWith('/api/')) {
response.headers.set('x-original-pathname', pathname + (request.nextUrl.search || ''))
}
return response
}
// 回退:现有 JWT 认证路径
if (pathname === '/login' || pathname.startsWith('/api/auth/login')) return NextResponse.next() if (pathname === '/login' || pathname.startsWith('/api/auth/login')) return NextResponse.next()
if (pathname.startsWith('/api/')) { if (pathname.startsWith('/api/')) {