diff --git a/CHANGELOG.md b/CHANGELOG.md index 65cda11..b9c95b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # 变更日志 +## 2026-05-14 + +- [修复] SSO 新用户免登录失败:根页面 `getCurrentUser()` 改为直接 `redirect('/dashboard')`,由中间件统一处理共享 JWT 认证(与 assets-ai 行为一致) +- [修复] `next.config.ts` 添加 `ldapts` 到 `serverExternalPackages`,确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 `ldapUserExists()` 因模块缺失失败导致 SSO 自动创建用户静默中断 +- [调整] 全局证书切换: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`:getCurrentUser() 更新 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-shared.ts`:共享 JWT 签发/验证(`tlyq_session` cookie,HS256,与 OA/assets 共用密钥) +- [调整] `src/lib/auth.ts`:`getCurrentUser()` 优先 `tlyq_session`,加入 LLDAP 存在性检查,用户被删除后自动清除 cookie 踢出 +- [调整] `src/app/api/auth/login/route.ts`:LDAP 优先认证 + 本地密码缓存回退 + localadmin 应急用户直连 +- [调整] `src/app/api/auth/logout/route.ts`:同时清除 `session_issue` 和 `tlyq_session` +- [调整] `src/app/api/auth/me/route.ts`:移除 SSO header 路径,改用 `getCurrentUser()` 统一获取 +- [调整] `src/middleware.ts`:优先 `tlyq_session` → 回退 `session_issue`,移除 SSO 代理路径,放行 `/api/internal/` +- [新增] `src/app/api/internal/roles/route.ts`:内部 API,返回站点可用角色列表(INTERNAL_API_KEY 鉴权) +- [新增] `src/app/api/users/[id]/route.ts`:admin/localadmin 用户禁止删除和修改角色 +- [修复] `src/components/tickets/TicketList.tsx`:已办工单点击业务IP/节点名称跳转到待办页面,改用 `usePathname()` 保持当前页面并正确筛选 +- [调整] `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 18156bd..94eb76a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,11 +99,16 @@ npm run import # 导入工单 ### 认证 +登录逻辑(v2.1):LDAP 优先 + 本地密码缓存回退 + localadmin 应急用户。 +登录成功签发两个 cookie:`session_issue`(本地 JWT,7 天)+ `tlyq_session`(共享 JWT,7 天,domain=.tlyq.ai)。 +中间件优先检查 `tlyq_session`,回退 `session_issue`。`getCurrentUser()` 每次验证时检查 LLDAP 用户是否存在(已删除则清除 cookie 踢出)。 + | 方法 | 路径 | 说明 | |------|------|------| -| POST | `/api/auth/login` | 登录(username + password → JWT cookie) | -| 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 鉴权) | ### 工单 @@ -133,6 +138,14 @@ npm run import # 导入工单 --- +## 认证机制 + +- **Web UI(v2.1)**:`middleware.ts` 优先检查 `tlyq_session`(共享 JWT,OA 统一签发)→ 回退 `session_issue`(本地 JWT)。`getCurrentUser()` 每次请求时检查 LLDAP 用户是否存在,已删除则清除 cookie 踢出 +- **localadmin**:纯本地 BCrypt 认证,不依赖 LLDAP,用于 LLDAP 故障时应急登录(DB 预置,admin 角色) +- **API Key**:`Bearer ak_<32位十六进制>`,存储时 SHA-256 hash,由 `ALLOWED_API_KEYS` 控制 + +--- + ## 环境配置 ### 本地与云端差异 @@ -225,7 +238,9 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai - **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 → `hasPermission()` 校验 - **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供 - **权限格式**:`resource:action`,如 `hasPermission(user, 'tickets:write')` -- **日期处理**:禁止使用 `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 b3590b9..cae7bcc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,8 +33,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libcairo2 \ && rm -rf /var/lib/apt/lists/* 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 /app/reports diff --git a/docker-compose.yml b/docker-compose.yml index 0ea31df..50e77ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,14 +10,21 @@ services: - issue-reports:/app/reports # .next 目录从主机挂载,npm run build 后直接生效,无需重建镜像 - ./.next:/app/.next + # 运行时从 LLDAP 容器动态读取 admin 密码 + - /var/run/docker.sock:/var/run/docker.sock environment: - DATABASE_PATH=/app/data/issue.db - - JWT_SECRET=${ISSUE_JWT_SECRET:-change-me-in-production} + - JWT_SECRET=oa-shared-jwt-secret-tlyq-2026 - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api} - ASSETS_API_KEY=${ASSETS_API_KEY} - ALLOWED_API_KEYS=${ALLOWED_API_KEYS} - NODE_ENV=production + - COOKIE_DOMAIN=.tlyq.ai + - 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 - TZ=Asia/Shanghai restart: unless-stopped networks: diff --git a/next.config.ts b/next.config.ts index dd4b3dd..966379f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,9 @@ 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'], // 确保 fs.readFileSync 加载的文件也被追踪到 standalone 输出中 // 防止 Docker 镜像中 npm install --omit=dev 漏装时缺失依赖 outputFileTracingIncludes: { diff --git a/oa-cloud-fixed.png b/oa-cloud-fixed.png new file mode 100644 index 0000000..a8bc38a Binary files /dev/null and b/oa-cloud-fixed.png differ diff --git a/oa-cloud-login.png b/oa-cloud-login.png new file mode 100644 index 0000000..7005a2f Binary files /dev/null and b/oa-cloud-login.png differ diff --git a/oa-login-current.png b/oa-login-current.png new file mode 100644 index 0000000..5a45c50 Binary files /dev/null and b/oa-login-current.png differ diff --git a/oa-login-page.png b/oa-login-page.png new file mode 100644 index 0000000..55293b4 Binary files /dev/null and b/oa-login-page.png differ diff --git a/package-lock.json b/package-lock.json index 6304c15..66a79ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "echarts": "^5.5.0", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", + "ldapts": "^8.1.7", "lucide-react": "^1.8.0", "next": "^15.1.0", "puppeteer": "^23.0.0", @@ -3592,6 +3593,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", @@ -4978,6 +4991,12 @@ "text-decoder": "^1.1.0" } }, + "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.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", diff --git a/package.json b/package.json index 72b2643..bd57457 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "echarts": "^5.5.0", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", + "ldapts": "^8.1.7", "lucide-react": "^1.8.0", "next": "^15.1.0", "puppeteer": "^23.0.0", diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(app)/settings/users/page.tsx b/src/app/(app)/settings/users/page.tsx index 58911a0..416249a 100644 --- a/src/app/(app)/settings/users/page.tsx +++ b/src/app/(app)/settings/users/page.tsx @@ -11,6 +11,8 @@ interface User { role: string is_active: number created_at: string + last_login_at: string | null + is_online: number } export default function UsersPage() { @@ -85,7 +87,7 @@ export default function UsersPage() { {loading ? (
加载中...
) : ( - +
{users.map(u => ( @@ -97,11 +99,20 @@ export default function UsersPage() { {u.is_active ? '启用' : '禁用'} + + @@ -115,7 +126,14 @@ export default function UsersPage() { setForm(p => ({ ...p, display_name: e.target.value }))} required /> setForm(p => ({ ...p, password: e.target.value }))} /> setForm(p => ({ ...p, email: e.target.value }))} /> - setForm(p => ({ ...p, role: e.target.value }))} /> + )} {error &&

{error}

}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 5dbf2c9..af84e03 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,16 +1,93 @@ import { NextRequest, NextResponse } from 'next/server' -import { login } from '@/lib/auth' +import { createToken } from '@/lib/auth' import { initDatabase } from '@/lib/db-schema' +import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt-shared' +import { ldapAuth } from '@/lib/ldap' +import { getDb } from '@/lib/db' +import bcrypt from 'bcryptjs' export async function POST(request: NextRequest) { try { initDatabase() const { username, password } = await request.json() if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 }) - const result = await login(username, password) - if (!result) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }) - const response = NextResponse.json({ user: result.user }) - response.cookies.set('session_issue', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60, path: '/' }) + + let userId: number + let role: string + let displayName: string + const db = getDb() + + // 1. localadmin:纯本地 BCrypt,不依赖 LLDAP + if (username === 'localadmin') { + const localUser = db.prepare( + 'SELECT * FROM users WHERE username = ? AND is_active = 1' + ).get(username) as { id: number; username: string; display_name: string; role: string; password_hash: string } | undefined + if (!localUser || !bcrypt.compareSync(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 = bcrypt.hashSync(password, 10) + 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 { id: number; username: string; display_name: string; role: string; password_hash: string } | undefined + if (!localUser || !bcrypt.compareSync(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 = await createToken({ id: userId, username, display_name: displayName, role }) + const sharedToken = signSharedJwt({ username, displayName }) + const sharedCfg = sharedCookieConfig() + + const response = NextResponse.json({ + user: { id: userId, username, display_name: displayName, role }, + }) + response.cookies.set('session_issue', localToken, { + httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60, path: '/', + }) + response.cookies.set(sharedCfg.name, sharedToken, sharedCfg) return response } catch (e) { console.error('Login error:', e); 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 b684ae7..32dba4c 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -3,8 +3,6 @@ import { NextResponse } from 'next/server' export async function POST() { const r = NextResponse.json({ success: true }) r.cookies.set('session_issue', '', { maxAge: 0, path: '/' }) - r.cookies.set('session', '', { maxAge: 0, path: '/' }) - // 清除 Authelia SSO cookie - r.cookies.set('authelia_session', '', { maxAge: 0, path: '/', domain: '127.0.0.1' }) + r.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' }) return r } diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 74b7e37..39000d1 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,45 +1,8 @@ import { NextResponse } from 'next/server' -import { cookies, headers } from 'next/headers' -import { getDb } from '@/lib/db' -import { getCurrentUser, createToken } from '@/lib/auth' +import { getCurrentUser } from '@/lib/auth' export async function GET() { try { - const cookieStore = await cookies() - - // 路径 1:SSO(来自 nginx auth_request 代理) - 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) { - const db = getDb() - 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, role FROM users WHERE username = ? AND is_active = 1' - ).get(ssoUsername) as Record | undefined - if (user) { - const jwt = await createToken({ - id: user.id as number, - username: user.username as string, - display_name: (user.display_name as string) || '', - role: user.role as string, - }) - const response = NextResponse.json({ user }) - response.cookies.set('session_issue', jwt, { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 7 * 24 * 60 * 60 }) - return response - } - } - - // 路径 2:JWT cookie(本地开发 / @fallback 紧急绕过) const user = await getCurrentUser() if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) return NextResponse.json({ user }) diff --git a/src/app/api/internal/roles/route.ts b/src/app/api/internal/roles/route.ts new file mode 100644 index 0000000..493c00f --- /dev/null +++ b/src/app/api/internal/roles/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' + +const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026' + +export async function GET(request: NextRequest) { + const key = request.headers.get('x-internal-key') + if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const db = getDb() + 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/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts index 2cb7dd1..084f837 100644 --- a/src/app/api/tickets/[id]/route.ts +++ b/src/app/api/tickets/[id]/route.ts @@ -90,7 +90,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ if (fields.length > 0) { fields.push('updated_by = ?') values.push(user.id) - fields.push("updated_at = datetime('now')") + fields.push("updated_at = datetime('now', '+8 hours')") values.push(id) db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values) } diff --git a/src/app/api/tickets/batch/route.ts b/src/app/api/tickets/batch/route.ts index 44f15b7..8e28c42 100644 --- a/src/app/api/tickets/batch/route.ts +++ b/src/app/api/tickets/batch/route.ts @@ -35,7 +35,7 @@ export async function PUT(request: NextRequest) { } if (fields.length === 0) continue - fields.push("updated_at = datetime('now')") + fields.push("updated_at = datetime('now', '+8 hours')") fields.push('updated_by = ?') values.push(user.id) values.push(item.id) diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 0df46da..0013586 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -15,9 +15,14 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const body = await request.json() const db = getDb() - 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 (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) { + return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 }) + } + const fields: string[] = [] const values: unknown[] = [] @@ -28,7 +33,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ if (body.password) { fields.push('password_hash = ?'); values.push(hashPassword(body.password)) } if (fields.length > 0) { - fields.push("updated_at = datetime('now')") + fields.push("updated_at = datetime('now', '+8 hours')") values.push(id) db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values) } @@ -52,8 +57,11 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis if (String(id) === String(user.id)) return NextResponse.json({ error: '不能删除自己' }, { status: 400 }) const db = getDb() - 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 d09ae85..5a7bf4e 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -13,7 +13,10 @@ export async function GET() { if (!hasPermission(user, 'users:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) const db = getDb() - 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 }) } catch (e) { const msg = e instanceof Error ? e.message : '查询失败' @@ -41,7 +44,7 @@ export async function POST(request: NextRequest) { const hash = hashPassword(password) const result = db.prepare( - 'INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)' + "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, hash, display_name, email || null, role || 'viewer') const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid) diff --git a/src/app/page.tsx b/src/app/page.tsx index 32320dc..28c5ca1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,5 @@ -export const dynamic = 'force-dynamic' - import { redirect } from 'next/navigation' -import { getCurrentUser } from '@/lib/auth' -import { initDatabase } from '@/lib/db-schema' -export default async function Home() { - initDatabase() - const user = await getCurrentUser() - if (user) redirect('/dashboard') - else redirect('/login') +export default function Home() { + redirect('/dashboard') } diff --git a/src/components/tickets/TicketList.tsx b/src/components/tickets/TicketList.tsx index 618ae80..1690131 100644 --- a/src/components/tickets/TicketList.tsx +++ b/src/components/tickets/TicketList.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useRef, Suspense } from 'react' import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' +import { useRouter, useSearchParams, usePathname } from 'next/navigation' import { Button, Badge, Pagination } from '@/components/ui' import { Search, Download, Eye, Pencil, Trash2, Filter, ArrowUpDown, ChevronsUpDown, ChevronUp, ChevronDown, Check, X, ExternalLink } from 'lucide-react' import SelectWithInput from '@/components/ui/SelectWithInput' @@ -148,6 +148,7 @@ interface TicketListInnerProps { function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions = true, hideDefaultFilterChips }: TicketListInnerProps) { const router = useRouter() const searchParams = useSearchParams() + const pathname = usePathname() const [tickets, setTickets] = useState([]) const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0, totalPages: 0 }) const [loading, setLoading] = useState(true) @@ -652,12 +653,12 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a8b9e8b..166821e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,11 +6,51 @@ import { createToken, verifyToken, type UserPayload } from './jwt' export { createToken, verifyToken, type UserPayload } +import { verifySharedJwt } from './jwt-shared' +import { ldapUserExists } from './ldap' + export async function getCurrentUser(): Promise { const cookieStore = await cookies() + + // 优先 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_issue', '', { maxAge: 0, path: '/' }) + return null + } + const db = getDb() + const row = db.prepare( + 'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1' + ).get(sharedPayload.username) as UserPayload | undefined + if (row) { + db.prepare("UPDATE users SET last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id) + return row + } + // 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, display_name, role FROM users WHERE username = ? AND is_active = 1' + ).get(sharedPayload.username) as UserPayload | undefined + if (newRow) return newRow + } + } + + // 回退 session_issue(本地 JWT,admin 账号或紧急绕过) const token = cookieStore.get('session_issue')?.value if (!token) return null - return verifyToken(token) + const payload = await verifyToken(token) + if (payload) { + const db2 = getDb() + db2.prepare("UPDATE users SET last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(payload.id) + } + return payload } export async function login(username: string, password: string) { @@ -52,6 +92,6 @@ export function verifyApiKey(key: string): ApiKeyInfo | null { .get(keyHash) as { id: number; name: string; permissions: string; expires_at: string | null } | 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 9297961..c71b2ba 100644 --- a/src/lib/db-schema.ts +++ b/src/lib/db-schema.ts @@ -4,14 +4,14 @@ import bcrypt from 'bcryptjs' export function initDatabase(): void { const db = getDb() const schema = [ - "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, 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')));", - "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')));", - "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')));", - "CREATE TABLE IF NOT EXISTS tickets (id INTEGER PRIMARY KEY, device_ip TEXT, device_sn TEXT, device_name TEXT, content TEXT, assign_time TEXT, close_time TEXT, duration_minutes INTEGER, availability REAL, process_summary TEXT, conclusion TEXT, fault_category TEXT, fault_subcategory TEXT, parts_replaced TEXT, parts_name TEXT, current_status TEXT NOT NULL DEFAULT 'open', counted_in_sla INTEGER NOT NULL DEFAULT 1, responsibility TEXT, created_by INTEGER REFERENCES users(id), updated_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')));", - "CREATE TABLE IF NOT EXISTS ticket_steps (id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, step_order INTEGER NOT NULL, time_node TEXT, handler TEXT, description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')));", - "CREATE TABLE IF NOT EXISTS reports (id INTEGER PRIMARY KEY AUTOINCREMENT, report_type TEXT NOT NULL, period_start TEXT, period_end TEXT, format TEXT NOT NULL DEFAULT 'pdf', file_path TEXT, file_name TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')));", - "CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id INTEGER, details TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')));", - "CREATE TABLE IF NOT EXISTS api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[\"tickets:read\"]', last_used_at TEXT, expires_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')));" + "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, email TEXT, role TEXT NOT NULL DEFAULT 'viewer', is_active INTEGER NOT NULL DEFAULT 1, 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', '+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', '+8 hours')));", + "CREATE TABLE IF NOT EXISTS tickets (id INTEGER PRIMARY KEY, device_ip TEXT, device_sn TEXT, device_name TEXT, content TEXT, assign_time TEXT, close_time TEXT, duration_minutes INTEGER, availability REAL, process_summary TEXT, conclusion TEXT, fault_category TEXT, fault_subcategory TEXT, parts_replaced TEXT, parts_name TEXT, current_status TEXT NOT NULL DEFAULT 'open', counted_in_sla INTEGER NOT NULL DEFAULT 1, responsibility TEXT, created_by INTEGER REFERENCES users(id), updated_by INTEGER REFERENCES users(id), 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 ticket_steps (id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, step_order INTEGER NOT NULL, time_node TEXT, handler TEXT, description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));", + "CREATE TABLE IF NOT EXISTS reports (id INTEGER PRIMARY KEY AUTOINCREMENT, report_type TEXT NOT NULL, period_start TEXT, period_end TEXT, format TEXT NOT NULL DEFAULT 'pdf', file_path TEXT, file_name TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));", + "CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id INTEGER, details TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));", + "CREATE TABLE IF NOT EXISTS api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[\"tickets:read\"]', last_used_at TEXT, expires_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));" ] for (const sql of schema) db.exec(sql) @@ -29,6 +29,10 @@ export function initDatabase(): void { db.prepare("UPDATE tickets SET ticket_type = 'OEM维修' WHERE ticket_type IS NULL AND fault_category IN ('硬件故障', '网络故障', '存储故障', '电源故障')").run() } catch { /* 迁移失败则保持原样 */ } + // 迁移:添加 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 { /* 列已存在 */ } + // 迁移:metadata 列(报告元数据 JSON) try { db.exec('ALTER TABLE reports ADD COLUMN metadata TEXT') } catch { /* 已存在 */ } @@ -45,7 +49,13 @@ export function initDatabase(): void { if (!existing) { const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123' const hash = bcrypt.hashSync(defaultPassword, 10) - db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)').run('admin', hash, '系统管理员', 'admin') + 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', hash, '系统管理员', 'admin') + } + const existingLocalAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('localadmin') + if (!existingLocalAdmin) { + const localPassword = process.env.LOCALADMIN_PASSWORD || 'admin123' + const localHash = bcrypt.hashSync(localPassword, 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', localHash, '本地管理员', 'admin') } const roles = [ { name: 'admin', display_name: '管理员', permissions: '["*"]' }, diff --git a/src/lib/jwt-shared.ts b/src/lib/jwt-shared.ts new file mode 100644 index 0000000..17b593a --- /dev/null +++ b/src/lib/jwt-shared.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 024f36e..bf067c7 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,81 +1,84 @@ import { NextRequest, NextResponse } from 'next/server' -import { verifyToken } from '@/lib/jwt' + +function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') + while (payload.length % 4) payload += '=' + return JSON.parse(atob(payload)) + } 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)) +} function verifyApiKey(key: string): boolean { const allowedKeys = process.env.ALLOWED_API_KEYS || '' if (!allowedKeys) return false - const keys = allowedKeys.split(',').map(k => k.trim()) - return keys.includes(key) + return allowedKeys.split(',').map(k => k.trim()).includes(key) } -export async function middleware(request: NextRequest) { +function buildLoginRedirect(request: NextRequest) { + const { pathname } = request.nextUrl + const loginUrl = new URL('/login', request.url) + const dest = pathname + (request.nextUrl.search || '') + loginUrl.searchParams.set('redirect', dest) + return NextResponse.redirect(loginUrl) +} + +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') - - // 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 === '/' - ? NextResponse.redirect(new URL('/tickets', 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 + // 登录/退出路径 + 内部 API 放行(自有 key 认证) + if (pathname.startsWith('/login') || pathname === '/' || + pathname === '/api/auth/login' || pathname === '/api/auth/logout' || + pathname.startsWith('/api/internal/')) { + return NextResponse.next() } - // 回退:现有 JWT 认证路径 - if (pathname.startsWith('/login') || pathname === '/') return NextResponse.next() - if (pathname === '/api/auth/login') return NextResponse.next() - + // API Key 认证(外部系统调用) const authHeader = request.headers.get('authorization') - if (authHeader?.startsWith('Bearer ak_')) { const key = authHeader.slice(7) if (verifyApiKey(key)) return NextResponse.next() if (pathname.startsWith('/api/')) return NextResponse.next() } - const token = request.cookies.get('session_issue')?.value - - function buildLoginRedirect() { - 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_issue') + // 优先检查 tlyq_session(共享 JWT) + const sharedToken = request.cookies.get('tlyq_session')?.value + const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null + if (isValidPayload(sharedPayload)) { + const response = pathname.startsWith('/api/') ? NextResponse.next() : NextResponse.next() + response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), { + httpOnly: true, sameSite: 'lax', path: '/', + }) return response } + // 回退 session_issue(本地 JWT) + const localToken = request.cookies.get('session_issue')?.value + const localPayload = localToken ? decodeJwtPayload(localToken) : null + if (pathname.startsWith('/api/')) { - if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) - const valid = await verifyToken(token) - if (!valid) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + if (!isValidPayload(localPayload)) { + return NextResponse.json({ error: '未登录' }, { status: 401 }) + } return NextResponse.next() } - if (!token) return buildLoginRedirect() - const valid = await verifyToken(token) - if (!valid) return buildLoginRedirect() + if (!isValidPayload(localPayload)) { + const response = buildLoginRedirect(request) + if (localToken) response.cookies.delete('session_issue') + return response + } const response = NextResponse.next() - response.headers.set('x-original-pathname', pathname + (request.nextUrl.search || '')) + response.cookies.set('session', JSON.stringify({ username: localPayload.username }), { + httpOnly: true, sameSite: 'lax', path: '/', + }) return response }
{u.username} + + + {u.is_online ? '在线' : '离线'} + + {u.last_login_at ? u.last_login_at : 从未登录} {u.created_at || '-'}
- + {u.username !== 'admin' && u.username !== 'localadmin' && ( + + )}
{t.id} {t.device_ip ? ( - {t.device_ip} + {t.device_ip} ) : '-'} {t.device_name ? ( - {t.device_name} + {t.device_name} ) : '-'} {t.content || '-'}