From 747fe293d5673b1a8bfff08ae413c355a940810a Mon Sep 17 00:00:00 2001
From: gitadmin
Date: Thu, 14 May 2026 16:37:49 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20SSO=20=E9=9B=86=E6=88=90=20=E2=80=94=20?=
=?UTF-8?q?=E5=85=B1=E4=BA=AB=20JWT=20+=20LDAP=20=E8=AE=A4=E8=AF=81=20+=20?=
=?UTF-8?q?=E8=B7=A8=E7=AB=99=E7=82=B9=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?=
=?UTF-8?q?=20API?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 src/lib/jwt.ts:共享 JWT 签发/验证(与 OA 共用密钥)
- 新增 src/lib/ldap.ts:LDAP 认证与用户存在性检查
- 新增 src/app/api/internal/roles/route.ts:内部 API 供 OA 查询角色
- 重构 auth.ts:双路径认证(共享 JWT + 本地 JWT 回退)
- 重构 middleware.ts:SSO 优先 + 本地认证回退
- 更新 docker-compose.yml:挂载 docker.sock 用于运行时 LLDAP 密码获取
- 更新 next.config.ts:serverExternalPackages 添加 ldapts
- 更新 Dockerfile:生产依赖安装优化
---
CHANGELOG.md | 30 +++++++++
CLAUDE.md | 16 +++--
Dockerfile | 10 +--
docker-compose.yml | 9 ++-
next.config.ts | 4 +-
package-lock.json | 19 ++++++
package.json | 1 +
public/.gitkeep | 0
src/app/(app)/assets/page.tsx | 10 ++-
src/app/(app)/layout.tsx | 46 ++++---------
src/app/(app)/settings/users/page.tsx | 23 ++++++-
src/app/api/assets/[id]/route.ts | 4 +-
src/app/api/assets/batch/route.ts | 4 +-
src/app/api/assets/import/route.ts | 4 +-
src/app/api/assets/route.ts | 2 +-
src/app/api/assets/template/route.ts | 10 +--
src/app/api/auth/login/route.ts | 93 +++++++++++++++++++++++++--
src/app/api/auth/logout/route.ts | 4 +-
src/app/api/auth/me/route.ts | 46 ++-----------
src/app/api/internal/roles/route.ts | 12 ++++
src/app/api/users/[id]/route.ts | 15 ++++-
src/app/api/users/route.ts | 7 +-
src/lib/auth.ts | 57 ++++++++++++----
src/lib/db-schema.ts | 42 +++++++++---
src/lib/jwt.ts | 61 ++++++++++++++++++
src/lib/ldap.ts | 71 ++++++++++++++++++++
src/middleware.ts | 88 ++++++++++++-------------
27 files changed, 506 insertions(+), 182 deletions(-)
create mode 100644 public/.gitkeep
create mode 100644 src/app/api/internal/roles/route.ts
create mode 100644 src/lib/jwt.ts
create mode 100644 src/lib/ldap.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae848cf..a303f49 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,35 @@
# 变更日志
+## 2026-05-14
+
+- [修复] `next.config.ts` 添加 `ldapts` 到 `serverExternalPackages`(预防性修复),确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 SSO 自动创建用户失败(issue-ai 已实际触发,参见 issue-ai CHANGELOG)
+- [调整] 全局证书切换:Cloudflare Origin CA → Let's Encrypt(`/etc/letsencrypt/live/www.tlyq.ai/`),覆盖全部 7 个子域名,nginx 8 个站点配置同步更新
+
+## 2026-05-12
+
+- [部署] 云端 JWT 密钥统一为 oa-shared-jwt-secret-tlyq-2026,三站点密钥一致
+- [部署] docker-compose 移除 AUTHELIA_URL,添加 LDAP/COOKIE/INTERNAL_API_KEY 环境变量
+- [部署] LLDAP admin 密码更新为 3Vm!Y!@RCiPs
+- [部署] nginx 移除 auth_request,恢复纯反向代理;改为运行时 DNS 解析
+- [新增] `src/lib/db-schema.ts`:users 表新增 last_login_at / last_active_at 列
+- [新增] `src/lib/auth.ts`:getSession() 更新 last_active_at
+- [新增] `src/app/api/auth/login/route.ts`:登录时更新 last_login_at 和 last_active_at
+
+## 2026-05-11
+
+- [新增] `src/lib/ldap.ts`:`ldapUserExists()` 函数,检查 LLDAP 中用户是否存在(admin bind 搜索,不可达时容错放行)
+- [新增] `src/lib/jwt.ts`:共享 JWT 签发/验证(`tlyq_session` cookie,HS256,与 OA/issue 共用密钥)
+- [调整] `src/lib/auth.ts`:`getSession()` 优先 `tlyq_session`,加入 LLDAP 存在性检查,用户被删除后自动清除 cookie 踢出
+- [调整] `src/app/api/auth/login/route.ts`:LDAP 优先认证 + 本地密码缓存回退 + localadmin 应急用户直连
+- [调整] `src/app/api/auth/logout/route.ts`:同时清除 `session_assets` 和 `tlyq_session`
+- [调整] `src/app/api/auth/me/route.ts`:移除 SSO header 路径,改用 `getSession()` 统一获取
+- [调整] `src/middleware.ts`:优先 `tlyq_session` → 回退 `session_assets`,移除 SSO 代理路径,放行 `/api/internal/`
+- [调整] `src/app/(app)/layout.tsx`:移除 SSO header 路径,统一从 `session` cookie 读取用户
+- [新增] `src/app/api/internal/roles/route.ts`:内部 API,返回站点可用角色列表(INTERNAL_API_KEY 鉴权)
+- [新增] `src/app/api/users/[id]/route.ts`:admin/localadmin 用户禁止删除和修改角色
+- [调整] `src/app/(app)/settings/users/page.tsx`:admin/localadmin 用户隐藏删除按钮,编辑时角色字段显示为只读
+- [新增] `src/lib/db-schema.ts`:预置 localadmin 应急用户(admin 角色,纯本地 BCrypt 认证)
+
## 2026-05-07
- [新增] 导出功能区分"导出选中"和"导出全部"两种模式,有选中时工具栏显示两个按钮独立操作
diff --git a/CLAUDE.md b/CLAUDE.md
index 583f800..e7257aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -86,11 +86,16 @@ npm run import # 导入设备数据
### 认证
+登录逻辑(v2.1):LDAP 优先 + 本地密码缓存回退 + localadmin 应急用户。
+登录成功签发两个 cookie:`session_assets`(本地 JWT,24h)+ `tlyq_session`(共享 JWT,7 天,domain=.tlyq.ai)。
+中间件优先检查 `tlyq_session`,回退 `session_assets`。`getSession()` 每次验证时检查 LLDAP 用户是否存在(已删除则清除 cookie 踢出)。
+
| 方法 | 路径 | 说明 |
|------|------|------|
-| POST | `/api/auth/login` | 登录(username + password → JWT cookie,24h 有效) |
-| POST | `/api/auth/logout` | 登出 |
+| POST | `/api/auth/login` | 登录(LDAP 优先 + 本地回退) |
+| POST | `/api/auth/logout` | 登出(清除两个 cookie) |
| GET | `/api/auth/me` | 当前用户信息 |
+| GET | `/api/internal/roles` | 内部 API:返回角色列表(x-internal-key 鉴权) |
### 资产
@@ -129,7 +134,8 @@ npm run import # 导入设备数据
## 认证机制
-- **Web UI**:`middleware.ts` 检查 cookie 中的 JWT token,payload 含 `{ userId, username, role, iat, exp }`
+- **Web UI(v2.1)**:`middleware.ts` 优先检查 `tlyq_session`(共享 JWT,OA 统一签发)→ 回退 `session_assets`(本地 JWT)。`getSession()` 每次请求时检查 LLDAP 用户是否存在,已删除则清除 cookie 踢出
+- **localadmin**:纯本地 BCrypt 认证,不依赖 LLDAP,用于 LLDAP 故障时应急登录(DB 预置,admin 角色)
- **API Key**:`Bearer ak_<32位十六进制>`,存储时 SHA-256 hash,验证时更新 `last_used_at`,权限由创建时指定的 `permissions` 数组控制
---
@@ -218,7 +224,9 @@ NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
-- **日期处理**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
+- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。两处必须遵守:
+ 1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
+ 2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 CREATE TABLE 的 DEFAULT 值、UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。
---
diff --git a/Dockerfile b/Dockerfile
index a4a1fe7..165c972 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,13 +8,15 @@ RUN --mount=type=cache,target=/root/.npm,id=assets-npm \
COPY . .
RUN npm run build
-# runner 阶段:与 builder 保持一致(Alpine + musl),确保 better-sqlite3 等原生模块兼容
-FROM node:20-alpine AS runner
-RUN apk add --no-cache python3 make g++
+# runner 阶段:使用 Debian(glibc),与 txjp 宿主机平台一致,避免原生模块 musl/glibc 不兼容
+FROM node:20-slim AS runner
WORKDIR /app
COPY --from=builder /app/package.json /app/package-lock.json ./
-RUN npm install --omit=dev
COPY --from=builder /app/.next/standalone ./
+RUN npm install --omit=dev && \
+ npm rebuild better-sqlite3 && \
+ rm -rf node_modules/@img/sharp-linuxmusl-x64 node_modules/@img/sharp-libvips-linuxmusl-x64 \
+ node_modules/@next/swc-linux-x64-musl
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
RUN mkdir -p /app/data /app/uploads
diff --git a/docker-compose.yml b/docker-compose.yml
index 9ec6009..5be8f80 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -9,12 +9,19 @@ services:
- assets-uploads:/app/uploads
# .next 目录从主机挂载,主机上 npm run build 后直接生效
- ./.next:/app/.next
+ # 运行时从 LLDAP 容器动态读取 admin 密码
+ - /var/run/docker.sock:/var/run/docker.sock
environment:
- DATABASE_PATH=/app/data/assets.db
- - JWT_SECRET=${ASSETS_JWT_SECRET:-change-me-in-production}
+ - JWT_SECRET=oa-shared-jwt-secret-tlyq-2026
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- NODE_ENV=production
+ - COOKIE_DOMAIN=.tlyq.ai
- TZ=Asia/Shanghai
+ - AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
+ - LDAP_URL=ldap://lldap:3890
+ - LDAP_BASE_DN=dc=tlyq,dc=ai
+ - LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
# issue-ai API 地址(容器内使用 issue-ai 服务名)
- ISSUE_API_URL=http://issue-ai:3000/api
# issue-ai API Key(用于服务间认证)
diff --git a/next.config.ts b/next.config.ts
index 326beba..a0be950 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -4,6 +4,8 @@ const nextConfig: NextConfig = {
images: { unoptimized: true },
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
- serverExternalPackages: ['better-sqlite3'],
+ // better-sqlite3: 原生模块,必须 external
+ // ldapts: SSO 自动创建用户依赖 LLDAP 验证,缺少则新用户无法免登录进入系统
+ serverExternalPackages: ['better-sqlite3', 'ldapts'],
}
export default nextConfig
diff --git a/package-lock.json b/package-lock.json
index 4695096..60e454d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0",
"cookie": "^1.0.2",
+ "ldapts": "^8.1.7",
"lucide-react": "^1.8.0",
"next": "^15.1.0",
"react": "^19.0.0",
@@ -2861,6 +2862,18 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/ldapts": {
+ "version": "8.1.7",
+ "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.7.tgz",
+ "integrity": "sha512-TJl6T92eIwMf/OJ0hDfKVa6ISwzo+lqCWCI5Mf//ARlKa3LKQZaSrme/H2rCRBhW0DZCQlrsV+fgoW5YHRNLUw==",
+ "license": "MIT",
+ "dependencies": {
+ "strict-event-emitter-types": "2.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3840,6 +3853,12 @@
"node": ">=0.8"
}
},
+ "node_modules/strict-event-emitter-types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
+ "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
+ "license": "ISC"
+ },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
diff --git a/package.json b/package.json
index 06b873c..4fb454c 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0",
"cookie": "^1.0.2",
+ "ldapts": "^8.1.7",
"lucide-react": "^1.8.0",
"next": "^15.1.0",
"react": "^19.0.0",
diff --git a/public/.gitkeep b/public/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/app/(app)/assets/page.tsx b/src/app/(app)/assets/page.tsx
index 3b5600c..6a07f99 100644
--- a/src/app/(app)/assets/page.tsx
+++ b/src/app/(app)/assets/page.tsx
@@ -379,13 +379,17 @@ export default function AssetsPage() {
- {selectedIds.size > 0 && (
+ {selectedIds.size > 0 && permissions.includes('assets:update') && (
)}
-
-
+ {permissions.includes('assets:import') && (
+
+ )}
+ {permissions.includes('assets:import') && (
+
+ )}
{canExportSelected && selectedIds.size > 0 && (
)}
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index 9b43ac3..c265c7e 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -1,45 +1,27 @@
export const dynamic = 'force-dynamic'
-import { cookies, headers } from 'next/headers'
+import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import db from '@/lib/db'
-import { verifyJwt } from '@/lib/auth'
import AppShell from '@/components/layout/AppShell'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies()
- const headersList = await headers()
- const originalPath = headersList.get('x-original-pathname') || ''
- const loginUrl = '/login' + (originalPath ? `?redirect=${encodeURIComponent(originalPath)}` : '')
- // 路径 1:SSO(来自 nginx auth_request 代理认证)
- let ssoUsername = ''
- const ssoSession = cookieStore.get('session')?.value
- if (ssoSession) {
- try { ssoUsername = JSON.parse(ssoSession).username || '' } catch { }
+ // 从 middleware 设置的 session cookie 获取用户名
+ const sessionCookie = cookieStore.get('session')?.value
+ let username = ''
+ if (sessionCookie) {
+ try { username = JSON.parse(sessionCookie).username || '' } catch { }
}
- if (!ssoUsername) ssoUsername = headersList.get('x-remote-user') || ''
+ if (!username) redirect('/login')
- 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
{children}
- }
- }
-
- // 路径 2:JWT cookie(本地开发 / @fallback 紧急绕过)
- const token = cookieStore.get('session_assets')?.value
- if (!token) redirect(loginUrl)
- const payload = verifyJwt(token)
- 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
- if (!user) redirect(loginUrl)
- return
{children}
+ 'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
+ ).get(username) as { id: number; username: string; display_name: string; role: string } | undefined
+
+ if (!user) redirect('/login')
+
+ return
{children}
}
diff --git a/src/app/(app)/settings/users/page.tsx b/src/app/(app)/settings/users/page.tsx
index 4f902c0..bdbd1f8 100644
--- a/src/app/(app)/settings/users/page.tsx
+++ b/src/app/(app)/settings/users/page.tsx
@@ -10,7 +10,8 @@ import { Plus, Edit, Trash2 } from 'lucide-react'
interface UserItem {
id: number; username: string; display_name: string; email: string | null;
- role: string; is_active: number; created_at: string
+ role: string; is_active: number; created_at: string; last_login_at: string | null;
+ is_online: number;
}
export default function UsersPage() {
@@ -90,11 +91,20 @@ export default function UsersPage() {
return
{option?.label || r.role}
} },
{ key: 'is_active', title: '状态', render: (r) =>
{r.is_active ? '启用' : '禁用'} },
+ { key: 'is_online', title: '在线', render: (r) => (
+
+
+ {r.is_online ? '在线' : '离线'}
+
+ )},
+ { key: 'last_login_at', title: '最后登录', render: (r) => r.last_login_at ?
{r.last_login_at} :
从未登录 },
{ key: 'created_at', title: '创建时间' },
{ key: 'actions', title: '操作', render: (r) => (
-
+ {r.username !== 'admin' && r.username !== 'localadmin' && (
+
+ )}
)},
]
@@ -120,7 +130,14 @@ export default function UsersPage() {
setForm(p => ({ ...p, display_name: e.target.value }))} />
setForm(p => ({ ...p, email: e.target.value }))} />
setForm(p => ({ ...p, password: e.target.value }))} />
-
diff --git a/src/app/api/assets/[id]/route.ts b/src/app/api/assets/[id]/route.ts
index fa0afb4..e2376e0 100644
--- a/src/app/api/assets/[id]/route.ts
+++ b/src/app/api/assets/[id]/route.ts
@@ -25,7 +25,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
- if (!checkPermission(session.role, 'assets:write')) {
+ if (!checkPermission(session.role, 'assets:update')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
@@ -70,7 +70,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 })
}
- updates.push("updated_at = datetime('now')")
+ updates.push("updated_at = datetime('now', '+8 hours')")
values.push(id)
db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values)
diff --git a/src/app/api/assets/batch/route.ts b/src/app/api/assets/batch/route.ts
index a439b41..fd215bd 100644
--- a/src/app/api/assets/batch/route.ts
+++ b/src/app/api/assets/batch/route.ts
@@ -14,7 +14,7 @@ const UPDATABLE_FIELDS = [
export async function POST(request: Request) {
const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
- if (!checkPermission(session.role, 'assets:write')) {
+ if (!checkPermission(session.role, 'assets:update')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
@@ -41,7 +41,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: '没有可更新的有效字段' }, { status: 400 })
}
- updates.push("updated_at = datetime('now')")
+ updates.push("updated_at = datetime('now', '+8 hours')")
const placeholders = ids.map(() => '?').join(', ')
const stmt = db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id IN (${placeholders})`)
diff --git a/src/app/api/assets/import/route.ts b/src/app/api/assets/import/route.ts
index f74e35c..38b1516 100644
--- a/src/app/api/assets/import/route.ts
+++ b/src/app/api/assets/import/route.ts
@@ -10,7 +10,7 @@ import { parseImportBuffer } from '@/lib/excel'
export async function POST(request: Request) {
const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
- if (!checkPermission(session.role, 'assets:write')) {
+ if (!checkPermission(session.role, 'assets:import')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
@@ -65,7 +65,7 @@ export async function POST(request: Request) {
}
}
if (updates.length > 0) {
- updates.push("updated_at = datetime('now')")
+ updates.push("updated_at = datetime('now', '+8 hours')")
values.push(existing.id)
db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values)
updated++
diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts
index 1c7d9b5..76a4f10 100644
--- a/src/app/api/assets/route.ts
+++ b/src/app/api/assets/route.ts
@@ -128,7 +128,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
- if (!checkPermission(session.role, 'assets:write')) {
+ if (!checkPermission(session.role, 'assets:create')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
diff --git a/src/app/api/assets/template/route.ts b/src/app/api/assets/template/route.ts
index 02eb7b0..79ca1d8 100644
--- a/src/app/api/assets/template/route.ts
+++ b/src/app/api/assets/template/route.ts
@@ -1,14 +1,14 @@
import { NextResponse } from 'next/server'
-import { cookies } from 'next/headers'
import { getSession } from '@/lib/auth'
+import { checkPermission } from '@/lib/permissions'
import { generateTemplateBuffer } from '@/lib/excel'
export async function GET() {
- const cookieStore = await cookies()
- const token = cookieStore.get('session_assets')?.value
- if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const payload = await getSession()
- if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
+ if (!payload) return NextResponse.json({ error: '未授权' }, { status: 401 })
+ if (!checkPermission(payload.role, 'assets:import')) {
+ return NextResponse.json({ error: '权限不足' }, { status: 403 })
+ }
const buffer = generateTemplateBuffer()
return new NextResponse(buffer, {
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index e76980c..d2a3dc0 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -1,18 +1,97 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db'
-import { verifyPassword, signJwt } from '@/lib/auth'
+import { verifyPassword, signJwt, hashPassword } from '@/lib/auth'
+import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
+import { ldapAuth } from '@/lib/ldap'
import type { User } from '@/types'
export async function POST(request: Request) {
try {
const { username, password } = await request.json()
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
- const user = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username) as User | undefined
- if (!user || !verifyPassword(password, user.password_hash)) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
- const token = signJwt({ userId: user.id, username: user.username, role: user.role })
+
+ let userId: number
+ let role: string
+ let displayName: string
+
+ // 1. localadmin:纯本地 BCrypt,不依赖 LLDAP
+ if (username === 'localadmin') {
+ const localUser = db.prepare(
+ 'SELECT * FROM users WHERE username = ? AND is_active = 1'
+ ).get(username) as User | undefined
+ if (!localUser || !verifyPassword(password, localUser.password_hash)) {
+ return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
+ }
+ userId = localUser.id
+ role = localUser.role
+ displayName = localUser.display_name || username
+ } else {
+ // 2. 其他用户:LLDAP 优先
+ const ldapResult = await ldapAuth(username, password)
+
+ if (ldapResult.success) {
+ // LDAP 认证成功 → 更新本地密码缓存 + 自动创建用户
+ displayName = ldapResult.displayName || username
+ const pwHash = hashPassword(password)
+ const existing = db.prepare(
+ 'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
+ ).get(username) as { id: number; role: string } | undefined
+
+ if (existing) {
+ db.prepare('UPDATE users SET password_hash = ?, display_name = ? WHERE id = ?')
+ .run(pwHash, displayName, existing.id)
+ userId = existing.id
+ role = existing.role
+ } else {
+ db.prepare(
+ "INSERT INTO users (username, password_hash, display_name, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
+ ).run(username, pwHash, displayName)
+ const created = db.prepare(
+ 'SELECT id, role FROM users WHERE username = ?'
+ ).get(username) as { id: number; role: string } | undefined
+ if (!created) return NextResponse.json({ error: '用户创建失败' }, { status: 500 })
+ userId = created.id
+ role = created.role
+ }
+ } else if (ldapResult.unreachable) {
+ // LLDAP 不可达 → 回退本地密码缓存
+ const localUser = db.prepare(
+ 'SELECT * FROM users WHERE username = ? AND is_active = 1'
+ ).get(username) as User | undefined
+ if (!localUser || !verifyPassword(password, localUser.password_hash)) {
+ return NextResponse.json({ error: '认证服务不可用,且本地密码不匹配' }, { status: 401 })
+ }
+ userId = localUser.id
+ role = localUser.role
+ displayName = localUser.display_name || username
+ } else {
+ return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
+ }
+ }
+
+ // 3. 更新最后登录时间和活跃时间
+ db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(userId)
+
+ // 4. 签发两个 cookie
+ const localToken = signJwt({ userId, username, role })
+ const sharedToken = signSharedJwt({ username, displayName })
+ const sharedCfg = sharedCookieConfig()
const cookieStore = await cookies()
- cookieStore.set('session_assets', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 86400, path: '/' })
- return NextResponse.json({ user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role } })
- } catch { return NextResponse.json({ error: '登录失败' }, { status: 500 }) }
+
+ cookieStore.set('session_assets', localToken, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 86400,
+ path: '/',
+ })
+ cookieStore.set(sharedCfg.name, sharedToken, sharedCfg)
+
+ return NextResponse.json({
+ user: { id: userId, username, display_name: displayName, role },
+ })
+ } catch {
+ return NextResponse.json({ error: '登录失败' }, { status: 500 })
+ }
}
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
index 946b06b..609bd1f 100644
--- a/src/app/api/auth/logout/route.ts
+++ b/src/app/api/auth/logout/route.ts
@@ -4,9 +4,7 @@ import { cookies } from 'next/headers'
export async function POST() {
const cookieStore = await cookies()
cookieStore.set('session_assets', '', { maxAge: 0, path: '/' })
- cookieStore.set('session', '', { maxAge: 0, path: '/' })
- // 清除 Authelia SSO cookie(domain 匹配才会生效)
- cookieStore.set('authelia_session', '', { maxAge: 0, path: '/', domain: '127.0.0.1' })
+ cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
return NextResponse.json({ success: true })
}
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index 72cd477..d0c4c83 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -1,56 +1,22 @@
import { NextResponse } from 'next/server'
-import { cookies, headers } from 'next/headers'
import db from '@/lib/db'
-import { getSession, verifyJwt, signJwt } from '@/lib/auth'
+import { getSession } from '@/lib/auth'
export async function GET() {
try {
- const cookieStore = await cookies()
+ const session = await getSession()
+ if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
- // 路径 1:SSO(来自 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 | 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
- }
- }
-
- // 路径 2:JWT cookie(本地开发 / @fallback 紧急绕过)
- const token = cookieStore.get('session_assets')?.value
- if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
- const payload = verifyJwt(token)
- 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 | undefined
+ ).get(session.userId) as Record | undefined
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 permissions: string[] = roleRow ? JSON.parse(roleRow.permissions) : []
+
return NextResponse.json({ user: { ...user, permissions } })
} catch {
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
diff --git a/src/app/api/internal/roles/route.ts b/src/app/api/internal/roles/route.ts
new file mode 100644
index 0000000..6f49fd5
--- /dev/null
+++ b/src/app/api/internal/roles/route.ts
@@ -0,0 +1,12 @@
+import { NextResponse } from 'next/server'
+import db from '@/lib/db'
+
+const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
+
+export async function GET(request: Request) {
+ const key = request.headers.get('x-internal-key')
+ if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+
+ const roles = db.prepare('SELECT name, display_name FROM roles ORDER BY name').all() as { name: string; display_name: string }[]
+ return NextResponse.json({ roles })
+}
diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts
index b339aa9..47cdb79 100644
--- a/src/app/api/users/[id]/route.ts
+++ b/src/app/api/users/[id]/route.ts
@@ -13,11 +13,17 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
}
const { id } = await params
- const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
+ const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
try {
const body = await request.json()
+
+ // 禁止修改系统保留用户的角色
+ if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) {
+ return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
+ }
+
const updates: string[] = []
const values: unknown[] = []
@@ -31,7 +37,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 })
}
- updates.push("updated_at = datetime('now')")
+ updates.push("updated_at = datetime('now', '+8 hours')")
values.push(id)
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values)
@@ -55,8 +61,11 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
return NextResponse.json({ error: '不能删除当前登录用户' }, { status: 400 })
}
- const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
+ const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
+ if (existing.username === 'admin' || existing.username === 'localadmin') {
+ return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
+ }
db.prepare('DELETE FROM users WHERE id = ?').run(id)
return NextResponse.json({ success: true })
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
index 18b923c..5195117 100644
--- a/src/app/api/users/route.ts
+++ b/src/app/api/users/route.ts
@@ -12,7 +12,10 @@ export async function GET() {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
- const users = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users ORDER BY id').all()
+ const users = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
+ last_login_at,
+ CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
+ FROM users ORDER BY id`).all()
return NextResponse.json({ users })
}
@@ -36,7 +39,7 @@ export async function POST(request: Request) {
}
const passwordHash = hashPassword(password)
- const result = db.prepare('INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)')
+ const result = db.prepare("INSERT INTO users (username, password_hash, display_name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))")
.run(username, passwordHash, display_name, email || null, role || 'viewer')
const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 7da93d4..320b35f 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -37,24 +37,53 @@ export function hashApiKey(key: string): string { return crypto.createHash('sha2
export function generateApiKey(): string { return `ak_${crypto.randomBytes(32).toString('hex')}` }
export function verifySession(token: string): SessionPayload | null { return verifyJwt(token) }
-// 统一获取当前会话:先查 JWT,再查 SSO header(解决首次加载时 JWT 尚未签发的竞态问题)
-import { cookies, headers } from 'next/headers'
+// 统一获取当前会话:优先 tlyq_session(共享 JWT),回退 session_assets(本地 JWT)
+import { cookies } from 'next/headers'
+import { verifySharedJwt } from '@/lib/jwt'
+import { ldapUserExists } from '@/lib/ldap'
+
export async function getSession(): Promise {
const cookieStore = await cookies()
- // 1. JWT
+
+ // 1. tlyq_session(共享 JWT,LDAP 用户)
+ const sharedToken = cookieStore.get('tlyq_session')?.value
+ if (sharedToken) {
+ const sharedPayload = verifySharedJwt(sharedToken)
+ if (sharedPayload) {
+ // Q1: 检查 LLDAP 中用户是否仍存在(已删除则强制退出)
+ if (!(await ldapUserExists(sharedPayload.username))) {
+ cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
+ cookieStore.set('session_assets', '', { maxAge: 0, path: '/' })
+ return null
+ }
+ const row = db.prepare(
+ 'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1'
+ ).get(sharedPayload.username) as { id: number; username: string; role: string } | undefined
+ if (row) {
+ db.prepare("UPDATE users SET last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
+ return { userId: row.id, username: row.username, role: row.role }
+ }
+ // SSO 免登录:LLDAP 验证通过但本地无记录 → 自动创建(viewer 角色)
+ db.prepare(
+ "INSERT OR IGNORE INTO users (username, display_name, role, is_active, created_at, updated_at) VALUES (?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
+ ).run(sharedPayload.username, sharedPayload.displayName)
+ const newRow = db.prepare(
+ 'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1'
+ ).get(sharedPayload.username) as { id: number; username: string; role: string } | undefined
+ if (newRow) {
+ return { userId: newRow.id, username: newRow.username, role: newRow.role }
+ }
+ }
+ }
+
+ // 2. session_assets(本地 JWT,admin 账号或紧急绕过)
const token = cookieStore.get('session_assets')?.value
if (token) {
const payload = verifyJwt(token)
- if (payload) return payload
- }
- // 2. SSO header(nginx 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
+ if (payload) {
+ db.prepare("UPDATE users SET last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(payload.userId)
+ return payload
+ }
}
return null
}
@@ -66,6 +95,6 @@ export function verifyApiKey(key: string): { id: number; name: string; permissio
.get(keyHash) as { id: number; name: string; permissions: string } | undefined
if (!row) return null
if (row.expires_at && new Date(row.expires_at) < new Date()) return null
- db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(row.id)
+ db.prepare("UPDATE api_keys SET last_used_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
return { id: row.id, name: row.name, permissions: JSON.parse(row.permissions) }
}
diff --git a/src/lib/db-schema.ts b/src/lib/db-schema.ts
index 2110243..9a90b66 100644
--- a/src/lib/db-schema.ts
+++ b/src/lib/db-schema.ts
@@ -11,21 +11,21 @@ export function initDatabase() {
email TEXT,
role TEXT NOT NULL DEFAULT 'viewer',
is_active INTEGER NOT NULL DEFAULT 1,
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
permissions TEXT NOT NULL DEFAULT '[]',
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -36,7 +36,7 @@ export function initDatabase() {
expires_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_by INTEGER REFERENCES users(id),
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -73,8 +73,8 @@ export function initDatabase() {
psu_total_power TEXT,
board_model TEXT, board_count INTEGER,
raw_data TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -85,7 +85,7 @@ export function initDatabase() {
entity_id INTEGER,
details TEXT,
ip_address TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);
CREATE INDEX IF NOT EXISTS idx_assets_node_name ON assets(node_name);
CREATE INDEX IF NOT EXISTS idx_assets_business_ip ON assets(business_ip);
@@ -93,15 +93,25 @@ export function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
`)
+ // 迁移:添加 last_login_at 和 last_active_at 列
+ try { db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT') } catch { /* 列已存在 */ }
+ try { db.exec('ALTER TABLE users ADD COLUMN last_active_at TEXT') } catch { /* 列已存在 */ }
+
const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
if (!existingAdmin) {
const passwordHash = bcrypt.hashSync(process.env.ADMIN_PASSWORD || 'admin123', 10)
- db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)')
+ db.prepare("INSERT INTO users (username, password_hash, display_name, role, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))")
.run('admin', passwordHash, '管理员', 'admin')
}
+ const existingLocalAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('localadmin')
+ if (!existingLocalAdmin) {
+ const localPasswordHash = bcrypt.hashSync(process.env.LOCALADMIN_PASSWORD || 'admin123', 10)
+ db.prepare("INSERT INTO users (username, password_hash, display_name, role, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))")
+ .run('localadmin', localPasswordHash, '本地管理员', 'admin')
+ }
const defaultRoles = [
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
- { name: 'editor', display_name: '编辑者', permissions: '["assets:read","assets:write","assets:delete","assets:export:selected"]' },
+ { name: 'editor', display_name: '编辑者', permissions: '["assets:read","assets:create","assets:import","assets:update","assets:delete","assets:export:selected"]' },
{ name: 'viewer', display_name: '查看者', permissions: '["assets:read"]' },
]
const builtinNames = new Set(defaultRoles.map(r => r.name))
@@ -128,4 +138,16 @@ export function initDatabase() {
db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(upgraded), r.id)
}
}
+
+ // 迁移自定义角色中遗留的旧 assets:write 权限(拆分为 create/import/update)
+ const allRoles2 = db.prepare('SELECT id, name, permissions FROM roles').all() as { id: number; name: string; permissions: string }[]
+ for (const r of allRoles2) {
+ if (builtinNames.has(r.name)) continue
+ const perms: string[] = JSON.parse(r.permissions)
+ if (perms.includes('assets:write')) {
+ const upgraded = perms.filter(p => p !== 'assets:write')
+ upgraded.push('assets:create', 'assets:import', 'assets:update')
+ db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(upgraded), r.id)
+ }
+ }
}
diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts
new file mode 100644
index 0000000..17b593a
--- /dev/null
+++ b/src/lib/jwt.ts
@@ -0,0 +1,61 @@
+import crypto from 'crypto'
+
+const JWT_SECRET = process.env.JWT_SECRET || 'change-me-same-across-all-sites'
+const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || ''
+
+export interface SharedSession {
+ username: string
+ displayName: string
+ iat: number
+ exp: number
+}
+
+function base64url(str: string): string {
+ return Buffer.from(str).toString('base64url')
+}
+
+export function signSharedJwt(
+ payload: { username: string; displayName: string },
+ expiresIn: number = 7 * 24 * 60 * 60
+): string {
+ const header = { alg: 'HS256', typ: 'JWT' }
+ const now = Math.floor(Date.now() / 1000)
+ const body = { ...payload, iat: now, exp: now + expiresIn }
+ const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))]
+ const signingInput = segments.join('.')
+ segments.push(
+ crypto.createHmac('sha256', JWT_SECRET).update(signingInput).digest('base64url')
+ )
+ return segments.join('.')
+}
+
+export function verifySharedJwt(token: string): SharedSession | null {
+ try {
+ const parts = token.split('.')
+ if (parts.length !== 3) return null
+ const signingInput = parts.slice(0, 2).join('.')
+ const expectedSig = crypto.createHmac('sha256', JWT_SECRET)
+ .update(signingInput).digest('base64url')
+ if (parts[2] !== expectedSig) return null
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString())
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
+ return {
+ username: payload.username,
+ displayName: payload.displayName,
+ iat: payload.iat,
+ exp: payload.exp,
+ }
+ } catch { return null }
+}
+
+export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) {
+ return {
+ name: 'tlyq_session',
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax' as const,
+ domain: COOKIE_DOMAIN,
+ path: '/',
+ maxAge,
+ }
+}
diff --git a/src/lib/ldap.ts b/src/lib/ldap.ts
new file mode 100644
index 0000000..262e057
--- /dev/null
+++ b/src/lib/ldap.ts
@@ -0,0 +1,71 @@
+import { Client, InvalidCredentialsError } from 'ldapts'
+import { execFileSync } from 'child_process'
+
+const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890'
+const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai'
+
+// 运行时从 LLDAP 容器动态获取 admin 密码,避免明文存于多个 .env
+// 需要容器挂载 /var/run/docker.sock
+function getLdapAdminPassword(): string {
+ try {
+ return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
+ { timeout: 3000 }).toString().trim()
+ } catch { return 'admin123' }
+}
+
+export interface LdapResult {
+ success: boolean
+ unreachable: boolean
+ username?: string
+ displayName?: string
+}
+
+export async function ldapAuth(
+ username: string,
+ password: string
+): Promise {
+ const userDn = `uid=${username},ou=people,${LDAP_BASE_DN}`
+ const client = new Client({ url: LDAP_URL, timeout: 5000 })
+
+ try {
+ await client.bind(userDn, password)
+ try {
+ const { searchEntries } = await client.search(LDAP_BASE_DN, {
+ scope: 'sub',
+ filter: `(uid=${username})`,
+ attributes: ['displayName'],
+ timeLimit: 3,
+ })
+ const displayName = (searchEntries[0] as any)?.displayName || username
+ return { success: true, unreachable: false, username, displayName }
+ } catch {
+ return { success: true, unreachable: false, username, displayName: username }
+ }
+ } catch (err) {
+ if (err instanceof InvalidCredentialsError) {
+ return { success: false, unreachable: false }
+ }
+ return { success: false, unreachable: true }
+ } finally {
+ await client.unbind()
+ }
+}
+
+// Q1: 检查 LLDAP 中用户是否存在(用 admin bind 搜索,不在/不可达均返回 true 保证容错)
+export async function ldapUserExists(username: string): Promise {
+ const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
+ const adminPass = getLdapAdminPassword()
+ const client = new Client({ url: LDAP_URL, timeout: 5000 })
+
+ try {
+ await client.bind(adminDn, adminPass)
+ const { searchEntries } = await client.search(LDAP_BASE_DN, {
+ scope: 'sub', filter: `(uid=${username})`, timeLimit: 3,
+ })
+ return searchEntries.length > 0
+ } catch {
+ return true // LLDAP 不可达 → 不阻断,容错放行
+ } finally {
+ await client.unbind()
+ }
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index 109dc98..b993e0a 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -11,67 +11,69 @@ function decodeJwtPayload(token: string): Record | null {
} catch { return null }
}
+function isValidPayload(payload: Record | null): boolean {
+ if (!payload) return false
+ return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
+}
+
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
- // 清除外部注入的 trust proxy headers(防伪造)
- const requestHeaders = new Headers(request.headers)
- requestHeaders.delete('x-remote-user')
- requestHeaders.delete('x-remote-groups')
+ // 登录/退出路径 + 内部 API 放行(自有 key 认证)
+ if (pathname === '/login' || pathname.startsWith('/api/auth/login') || pathname === '/api/auth/logout' || pathname.startsWith('/api/internal/')) {
+ return NextResponse.next()
+ }
- // 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'
+ // API 路由:检查 session_assets cookie 或 Bearer API Key
+ if (pathname.startsWith('/api/')) {
+ const authHeader = request.headers.get('authorization')
+ if (authHeader?.startsWith('Bearer ak_')) return NextResponse.next()
- if (remoteUser && isFromNginx) {
- // logout 路径不设置 SSO session(避免清除后又重新设置)
- if (pathname === '/api/auth/logout') return NextResponse.next()
+ const sharedToken = request.cookies.get('tlyq_session')?.value
+ const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
+ if (isValidPayload(sharedPayload)) return NextResponse.next()
- const response = pathname === '/login' || pathname.startsWith('/api/auth/login')
- ? NextResponse.redirect(new URL('/', request.url))
- : NextResponse.next()
+ const localToken = request.cookies.get('session_assets')?.value
+ const localPayload = localToken ? decodeJwtPayload(localToken) : null
+ if (isValidPayload(localPayload)) return NextResponse.next()
- response.cookies.set('session', JSON.stringify({ username: remoteUser }), {
+ return NextResponse.json({ error: '未授权' }, { status: 401 })
+ }
+
+ // 页面路由:优先检查 tlyq_session(共享 JWT),回退 session_assets(本地 JWT)
+ const sharedToken = request.cookies.get('tlyq_session')?.value
+ const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
+
+ if (isValidPayload(sharedPayload)) {
+ const response = NextResponse.next()
+ response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), {
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()
+ const localToken = request.cookies.get('session_assets')?.value
+ const localPayload = localToken ? decodeJwtPayload(localToken) : null
- if (pathname.startsWith('/api/')) {
- const authHeader = request.headers.get('authorization')
- if (authHeader?.startsWith('Bearer ak_')) return NextResponse.next()
- const token = request.cookies.get('session_assets')?.value
- const payload = token ? decodeJwtPayload(token) : null
- if (!payload?.userId) {
- return NextResponse.json({ error: '未授权' }, { status: 401 })
- }
- return NextResponse.next()
- }
-
- const token = request.cookies.get('session_assets')?.value
- const payload = token ? decodeJwtPayload(token) : null
- const isValidToken = payload?.userId != null
-
- if (!isValidToken) {
- const loginUrl = new URL('/login', request.url)
- const dest = pathname + (request.nextUrl.search || '')
- loginUrl.searchParams.set('redirect', dest)
- const response = NextResponse.redirect(loginUrl)
- if (token) response.cookies.delete('session_assets')
+ if (isValidPayload(localPayload)) {
+ const response = NextResponse.next()
+ response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ })
return response
}
- const response = NextResponse.next()
- response.headers.set('x-original-pathname', pathname + (request.nextUrl.search || ''))
+ // 未认证 → 重定向登录页
+ const loginUrl = new URL('/login', request.url)
+ const dest = pathname + (request.nextUrl.search || '')
+ loginUrl.searchParams.set('redirect', dest)
+ const response = NextResponse.redirect(loginUrl)
+ if (sharedToken) response.cookies.delete('tlyq_session')
+ if (localToken) response.cookies.delete('session_assets')
return response
}