From b9c83617ad29c9adffd45d67702b1ddad8c65329 Mon Sep 17 00:00:00 2001 From: liuyh Date: Wed, 18 Mar 2026 08:57:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BE=9B=E5=8C=97=E4=BA=AC=E5=A4=A7?= =?UTF-8?q?=E5=AD=A6=E7=BB=9F=E4=B8=80=E8=AE=A4=E8=AF=81=E6=94=AF=E6=8C=81?= =?UTF-8?q?=EF=BC=8C=E4=BB=85=E9=9C=80=E9=85=8D=E7=BD=AE=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 + src/app/(auth)/login/login-form.tsx | 243 ++++++++++++++++++++++++ src/app/(auth)/login/page.tsx | 133 ++----------- src/app/api/auth/iaaa/callback/route.ts | 93 +++++++++ src/server/auth.ts | 61 +++--- src/server/service/iaaa.ts | 89 +++++++++ tsconfig.json | 22 ++- 7 files changed, 502 insertions(+), 147 deletions(-) create mode 100644 src/app/(auth)/login/login-form.tsx create mode 100644 src/app/api/auth/iaaa/callback/route.ts create mode 100644 src/server/service/iaaa.ts diff --git a/.env.example b/.env.example index 8e49704..36f02ee 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,14 @@ NEXTAUTH_SECRET= PKUAI_API_KEY= PKUAI_API_BASE= +# 北京大学 IAAA 统一认证(可选,不配置则不启用) +## 在 IAAA 注册的应用系统 ID +IAAA_APP_ID= +## IAAA 提供的密钥,用于生成消息摘要 +IAAA_KEY= +## 设为 true 则进入登录页后自动跳转到统一认证(默认 false) +IAAA_AUTO_REDIRECT= + diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx new file mode 100644 index 0000000..2f45022 --- /dev/null +++ b/src/app/(auth)/login/login-form.tsx @@ -0,0 +1,243 @@ +'use client' + +import { useEffect, useRef, useState } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { signIn } from "next-auth/react" +import { useRouter, useSearchParams } from "next/navigation" +import { z } from "zod" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { AlertTriangle, ExternalLink } from "lucide-react" +import type { IaaaClientConfig } from "./page" + +// 登录表单验证 schema +const loginSchema = z.object({ + id: z.string().min(1, "请输入用户ID"), + password: z.string().min(1, "请输入密码"), +}) + +type LoginFormData = z.infer + +/** IAAA 回调错误码到用户提示的映射 */ +const IAAA_ERROR_MESSAGES: Record = { + iaaa_no_token: '未收到认证票据,请重试', + iaaa_validate_failed: '统一认证验证失败,请重试', + iaaa_user_not_found: '系统中不存在与您北大账号对应的用户,请联系管理员', + iaaa_server_error: '服务器内部错误,请稍后重试', +} + +/** 创建隐藏表单 POST 跳转到 IAAA 统一认证页面 */ +function redirectToIaaa(appId: string, callbackPath: string) { + const form = document.createElement('form') + form.action = 'https://iaaa.pku.edu.cn/iaaa/oauth.jsp' + form.method = 'POST' + form.style.display = 'none' + + function addField(name: string, value: string) { + const input = document.createElement('input') + input.type = 'hidden' + input.name = name + input.value = value + form.appendChild(input) + } + + const baseUrl = `${location.protocol}//${location.host}` + addField('appID', appId) + addField('redirectUrl', `${baseUrl}${callbackPath}`) + // 保留应用自有登录入口,以便用户可以选择回到密码登录 + addField('redirectLogonUrl', `${baseUrl}/login`) + + document.body.appendChild(form) + form.submit() +} + +export function LoginForm({ iaaaConfig }: { iaaaConfig: IaaaClientConfig }) { + const router = useRouter() + const searchParams = useSearchParams() + const autoRedirected = useRef(false) + const [showDevHint, setShowDevHint] = useState(false) + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + id: "", + password: "", + }, + }) + + // IAAA 回调错误提示(使用 iaaa_error 参数名,避免与 next-auth 的 error 参数冲突) + const iaaaErrorCode = searchParams.get('iaaa_error') + const iaaaError = iaaaErrorCode ? IAAA_ERROR_MESSAGES[iaaaErrorCode] : null + + // 自动跳转:配置开启且 IAAA 已启用时,进入登录页自动跳转到统一认证 + // 仅在非错误回调场景下触发(避免验证失败后死循环) + useEffect(() => { + if ( + iaaaConfig.enabled && + iaaaConfig.autoRedirect && + !iaaaErrorCode && + !autoRedirected.current + ) { + autoRedirected.current = true + redirectToIaaa(iaaaConfig.appId, iaaaConfig.callbackPath) + } + }, [iaaaConfig, iaaaErrorCode]) + + const onSubmit = async (data: LoginFormData) => { + try { + const result = await signIn("credentials", { + id: data.id, + password: data.password, + redirect: false, + }) + + if (result?.error) { + form.setError("root", { + type: "manual", + message: "用户ID或密码错误" + }) + } else if (result?.ok) { + router.push("/") + router.refresh() + } + } catch (error) { + form.setError("root", { + type: "manual", + message: "登录失败,请重试" + }) + console.error("Login error:", error) + } + } + + // IAAA 是否可见:已启用,或开发模式下需要显示配置提醒 + const showIaaa = iaaaConfig.enabled || iaaaConfig.showInDev + + return ( +
+ + + 登录 + + + {/* IAAA 回调错误提示 */} + {iaaaError && ( + + + {iaaaError} + + )} + + {/* 密码登录表单 */} +
+ + ( + + 用户ID + + + + + + )} + /> + ( + + 密码 + + + + + + )} + /> + {form.formState.errors.root && ( +
+ {form.formState.errors.root.message} +
+ )} + + + + + {/* IAAA 统一认证登录 */} + {showIaaa && ( + <> + + + )} +
+
+ + {/* 开发模式下未配置 IAAA 的提示对话框(仅开发模式渲染,避免泄露内部架构) */} + {iaaaConfig.showInDev && + + + 北大统一认证未配置 + + IAAA 统一认证功能需要配置以下环境变量才能使用: + + +
+

1. 在 IAAA 平台注册应用时填写:

+
+
系统URL:https://你的域名
+
服务器IP:应用服务器的出口IP
+
回调URL:https://你的域名/api/auth/iaaa/callback
+
+

2. 将获取的凭据写入 .env 文件:

+
+
IAAA_APP_ID=你的应用ID
+
IAAA_KEY=你的密钥
+
# 可选:进入登录页自动跳转统一认证
+
IAAA_AUTO_REDIRECT=true
+
+

+ 配置完成后重启开发服务器即可生效。 +

+
+
+
} +
+ ) +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 26e4b13..73d9e4f 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,122 +1,23 @@ -'use client' +import { getIaaaClientConfig } from "@/server/service/iaaa" +import { LoginForm } from "./login-form" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { signIn } from "next-auth/react" -import { useRouter } from "next/navigation" -import { z } from "zod" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" - -// 登录表单验证 schema -const loginSchema = z.object({ - id: z.string().min(1, "请输入用户ID"), - password: z.string().min(1, "请输入密码"), -}) - -type LoginFormData = z.infer +export interface IaaaClientConfig { + enabled: boolean + appId: string + callbackPath: string + autoRedirect: boolean + /** 开发模式下未配置 IAAA,需要显示配置提醒 */ + showInDev: boolean +} export default function LoginPage() { - const router = useRouter() + const config = getIaaaClientConfig() + const isDev = process.env.NODE_ENV === 'development' - const form = useForm({ - resolver: zodResolver(loginSchema), - defaultValues: { - id: "", - password: "", - }, - }) - - const onSubmit = async (data: LoginFormData) => { - try { - const result = await signIn("credentials", { - id: data.id, - password: data.password, - redirect: false, - }) - - if (result?.error) { - form.setError("root", { - type: "manual", - message: "用户ID或密码错误" - }) - } else if (result?.ok) { - // 登录成功,重定向到首页 - router.push("/") - router.refresh() - } - } catch (error) { - form.setError("root", { - type: "manual", - message: "登录失败,请重试" - }) - console.error("Login error:", error) - } + const iaaaConfig: IaaaClientConfig = { + ...config, + showInDev: isDev && !config.enabled, } - return ( -
- - - 登录 - - -
- - ( - - 用户ID - - - - - - )} - /> - ( - - 密码 - - - - - - )} - /> - {form.formState.errors.root && ( -
- {form.formState.errors.root.message} -
- )} - - - -
-
-
- ) -} \ No newline at end of file + return +} diff --git a/src/app/api/auth/iaaa/callback/route.ts b/src/app/api/auth/iaaa/callback/route.ts new file mode 100644 index 0000000..380c0bc --- /dev/null +++ b/src/app/api/auth/iaaa/callback/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from 'next/server' +import { encode } from 'next-auth/jwt' +import { db } from '@/server/db' +import { authOptions, buildUserJwtPayload, userAuthInclude } from '@/server/auth' +import { validateIaaaToken } from '@/server/service/iaaa' +import { clearSessionInvalidation } from '@/server/service/session' + +/** + * IAAA 统一认证回调路由 + * + * IAAA 认证成功后会 302 重定向到此路由,携带 token 参数。 + * 本路由验证 token、查找本地用户、生成 JWT session cookie,然后重定向到首页。 + */ +export async function GET(req: NextRequest) { + const token = req.nextUrl.searchParams.get('token') + const loginUrl = new URL('/login', req.url) + + if (!token) { + loginUrl.searchParams.set('iaaa_error', 'iaaa_no_token') + return NextResponse.redirect(loginUrl) + } + + // 获取客户端真实 IP(反向代理场景取 X-Forwarded-For 第一个值) + const remoteAddr = + req.headers.get('x-forwarded-for')?.split(',')[0].trim() || + req.headers.get('x-real-ip') || + '127.0.0.1' + + // 调用 IAAA 验证 + const userInfo = await validateIaaaToken(token, remoteAddr) + if (!userInfo) { + loginUrl.searchParams.set('iaaa_error', 'iaaa_validate_failed') + return NextResponse.redirect(loginUrl) + } + + // 用 identityId(学号/职工号)匹配本地用户 + const user = await db.user.findUnique({ + where: { id: userInfo.identityId }, + include: userAuthInclude, + }) + + if (!user) { + console.warn(`[IAAA] 用户 ${userInfo.identityId}(${userInfo.name})在系统中不存在`) + loginUrl.searchParams.set('iaaa_error', 'iaaa_user_not_found') + return NextResponse.redirect(loginUrl) + } + + // 更新最近登录时间 + await db.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }) + + // 清除会话失效标记 + await clearSessionInvalidation(user.id) + + // 构建 JWT payload(与密码登录完全一致) + const payload = buildUserJwtPayload(user) + + // 生成 next-auth 兼容的 JWT + const secret = authOptions.secret || process.env.NEXTAUTH_SECRET + if (!secret) { + console.error('[IAAA] NEXTAUTH_SECRET 未配置') + loginUrl.searchParams.set('iaaa_error', 'iaaa_server_error') + return NextResponse.redirect(loginUrl) + } + + const maxAge = authOptions.session?.maxAge ?? 30 * 24 * 60 * 60 + const encodedToken = await encode({ + token: { ...payload, sub: payload.id }, + secret, + maxAge, + }) + + // 设置 session cookie 并重定向到首页 + // next-auth 在 HTTPS 环境下使用 __Secure- 前缀 + const useSecureCookie = req.nextUrl.protocol === 'https:' + const cookieName = useSecureCookie + ? '__Secure-next-auth.session-token' + : 'next-auth.session-token' + + const response = NextResponse.redirect(new URL('/', req.url)) + response.cookies.set(cookieName, encodedToken, { + httpOnly: true, + secure: useSecureCookie, + sameSite: 'lax', + path: '/', + maxAge, + }) + + console.info(`[IAAA] 登录成功: ${userInfo.identityId}(${userInfo.name})`) + return response +} diff --git a/src/server/auth.ts b/src/server/auth.ts index 5ff6d6e..5ecde5e 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -6,6 +6,36 @@ import bcrypt from "bcryptjs" import { db } from "./db" import { clearSessionInvalidation, isSessionInvalidated } from "./service/session" +/** 用户查询时需要 include 的关联 */ +export const userAuthInclude = { + roles: { include: { permissions: true } }, + dept: true, +} as const + +/** 从数据库用户对象构建 JWT payload(供密码登录和 IAAA 登录共用) */ +export function buildUserJwtPayload(user: { + id: string + name: string | null + status: string | null + deptCode: string | null + isSuperAdmin: boolean + roles: Array<{ name: string; permissions: Array<{ name: string }> }> +}) { + const roles = user.roles.map((r) => r.name) + const permissions = Array.from( + new Set(user.roles.flatMap((r) => r.permissions.map((p) => p.name))) + ) + return { + id: user.id, + name: user.name, + status: user.status, + deptCode: user.deptCode, + roles, + permissions, + isSuperAdmin: user.isSuperAdmin, + } +} + export const authOptions: NextAuthOptions = { providers: [ CredentialsProvider({ @@ -22,15 +52,8 @@ export const authOptions: NextAuthOptions = { try { // 查找用户 const user = await db.user.findUnique({ - where: { - id: credentials.id - }, - include: { - roles: { - include: { permissions: true } - }, - dept: true - } + where: { id: credentials.id }, + include: userAuthInclude, }) if (!user) { @@ -39,7 +62,6 @@ export const authOptions: NextAuthOptions = { // 验证密码 const isPasswordValid = await bcrypt.compare(credentials.password, user.password) - if (!isPasswordValid) { return null @@ -53,23 +75,8 @@ export const authOptions: NextAuthOptions = { // 清除会话失效标记(用户已重新登录,获得最新权限) await clearSessionInvalidation(user.id) - - // 返回用户信息、角色和权限 - const roles = user.roles.map((r) => r.name) - const permissions = Array.from( - new Set(user.roles.flatMap((r) => - r.permissions.map((p) => p.name) - )) - ) - return { - id: user.id, - name: user.name, - status: user.status, - deptCode: user.deptCode, - roles, - permissions, - isSuperAdmin: user.isSuperAdmin - } as any + + return buildUserJwtPayload(user) as any } catch (error) { console.error("Auth error:", error) return null diff --git a/src/server/service/iaaa.ts b/src/server/service/iaaa.ts new file mode 100644 index 0000000..2108bd3 --- /dev/null +++ b/src/server/service/iaaa.ts @@ -0,0 +1,89 @@ +import 'server-only' +import crypto from 'crypto' + +const IAAA_VALIDATE_URL = 'https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do' + +export interface IaaaUserInfo { + name: string + status: string + identityId: string + deptId: string + dept: string + identityType: string + detailType: string + identityStatus: string + campus: string +} + +interface IaaaValidateResult { + success: boolean + errCode: string + errMsg: string + userInfo?: IaaaUserInfo +} + +/** 检查 IAAA 统一认证是否已配置启用 */ +export function isIaaaEnabled(): boolean { + return !!(process.env.IAAA_APP_ID && process.env.IAAA_KEY) +} + +/** 获取 IAAA 配置(仅返回前端需要的非敏感信息) */ +export function getIaaaClientConfig() { + return { + enabled: isIaaaEnabled(), + appId: process.env.IAAA_APP_ID || '', + callbackPath: '/api/auth/iaaa/callback', + autoRedirect: process.env.IAAA_AUTO_REDIRECT === 'true', + } +} + +/** + * 验证 IAAA Token + * @param token IAAA 回调携带的 token + * @param remoteAddr 用户真实 IP 地址 + * @returns 验证成功返回用户信息,失败返回 null 并记录日志 + */ +export async function validateIaaaToken( + token: string, + remoteAddr: string +): Promise { + const appId = process.env.IAAA_APP_ID + const key = process.env.IAAA_KEY + + if (!appId || !key) { + console.error('[IAAA] 环境变量 IAAA_APP_ID 或 IAAA_KEY 未配置') + return null + } + + // 参数按字母序拼接:appId, remoteAddr, token + const paraStr = `appId=${appId}&remoteAddr=${remoteAddr}&token=${token}` + const msgAbs = crypto.createHash('md5').update(paraStr + key).digest('hex') + + const url = `${IAAA_VALIDATE_URL}?${paraStr}&msgAbs=${msgAbs}` + + try { + const resp = await fetch(url, { signal: AbortSignal.timeout(10000) }) + if (!resp.ok) { + console.error(`[IAAA] 验证请求失败: HTTP ${resp.status}`) + return null + } + + const result: IaaaValidateResult = await resp.json() + + if (!result.success) { + console.warn(`[IAAA] 认证失败: errCode=${result.errCode}, errMsg=${result.errMsg}`) + return null + } + + const userInfo = result.userInfo + if (!userInfo?.identityId) { + console.warn('[IAAA] 认证成功但未返回 identityId') + return null + } + + return userInfo + } catch (error) { + console.error('[IAAA] Token 验证请求异常:', error) + return null + } +} diff --git a/tsconfig.json b/tsconfig.json index c133409..ba6d3aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,9 +23,19 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "next-env.d.ts", + ".next-prod/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }