提供北京大学统一认证支持,仅需配置环境变量
This commit is contained in:
@@ -51,6 +51,14 @@ NEXTAUTH_SECRET=
|
|||||||
PKUAI_API_KEY=
|
PKUAI_API_KEY=
|
||||||
PKUAI_API_BASE=
|
PKUAI_API_BASE=
|
||||||
|
|
||||||
|
# 北京大学 IAAA 统一认证(可选,不配置则不启用)
|
||||||
|
## 在 IAAA 注册的应用系统 ID
|
||||||
|
IAAA_APP_ID=
|
||||||
|
## IAAA 提供的密钥,用于生成消息摘要
|
||||||
|
IAAA_KEY=
|
||||||
|
## 设为 true 则进入登录页后自动跳转到统一认证(默认 false)
|
||||||
|
IAAA_AUTO_REDIRECT=
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
243
src/app/(auth)/login/login-form.tsx
Normal file
243
src/app/(auth)/login/login-form.tsx
Normal file
@@ -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<typeof loginSchema>
|
||||||
|
|
||||||
|
/** IAAA 回调错误码到用户提示的映射 */
|
||||||
|
const IAAA_ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
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<LoginFormData>({
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl text-center">登录</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* IAAA 回调错误提示 */}
|
||||||
|
{iaaaError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{iaaaError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 密码登录表单 */}
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>用户ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户ID"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>密码</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.root && (
|
||||||
|
<div className="text-sm text-red-600 text-center">
|
||||||
|
{form.formState.errors.root.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? "登录中..." : "登录"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{/* IAAA 统一认证登录 */}
|
||||||
|
{showIaaa && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
if (iaaaConfig.enabled) {
|
||||||
|
redirectToIaaa(iaaaConfig.appId, iaaaConfig.callbackPath)
|
||||||
|
} else {
|
||||||
|
setShowDevHint(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
北京大学统一认证登录
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 开发模式下未配置 IAAA 的提示对话框(仅开发模式渲染,避免泄露内部架构) */}
|
||||||
|
{iaaaConfig.showInDev && <Dialog open={showDevHint} onOpenChange={setShowDevHint}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>北大统一认证未配置</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
IAAA 统一认证功能需要配置以下环境变量才能使用:
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p className="font-medium">1. 在 IAAA 平台注册应用时填写:</p>
|
||||||
|
<div className="rounded-md bg-muted p-3 font-mono text-xs space-y-1">
|
||||||
|
<div>系统URL:<span className="text-muted-foreground">https://你的域名</span></div>
|
||||||
|
<div>服务器IP:<span className="text-muted-foreground">应用服务器的出口IP</span></div>
|
||||||
|
<div>回调URL:<span className="text-muted-foreground">https://你的域名/api/auth/iaaa/callback</span></div>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">2. 将获取的凭据写入 <code className="bg-muted px-1 py-0.5 rounded">.env</code> 文件:</p>
|
||||||
|
<div className="rounded-md bg-muted p-3 font-mono text-xs space-y-1">
|
||||||
|
<div>IAAA_APP_ID=你的应用ID</div>
|
||||||
|
<div>IAAA_KEY=你的密钥</div>
|
||||||
|
<div className="text-muted-foreground"># 可选:进入登录页自动跳转统一认证</div>
|
||||||
|
<div>IAAA_AUTO_REDIRECT=true</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
配置完成后重启开发服务器即可生效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,122 +1,23 @@
|
|||||||
'use client'
|
import { getIaaaClientConfig } from "@/server/service/iaaa"
|
||||||
|
import { LoginForm } from "./login-form"
|
||||||
|
|
||||||
import { useForm } from "react-hook-form"
|
export interface IaaaClientConfig {
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
enabled: boolean
|
||||||
import { signIn } from "next-auth/react"
|
appId: string
|
||||||
import { useRouter } from "next/navigation"
|
callbackPath: string
|
||||||
import { z } from "zod"
|
autoRedirect: boolean
|
||||||
import { Button } from "@/components/ui/button"
|
/** 开发模式下未配置 IAAA,需要显示配置提醒 */
|
||||||
import { Input } from "@/components/ui/input"
|
showInDev: boolean
|
||||||
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<typeof loginSchema>
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const config = getIaaaClientConfig()
|
||||||
|
const isDev = process.env.NODE_ENV === 'development'
|
||||||
|
|
||||||
const form = useForm<LoginFormData>({
|
const iaaaConfig: IaaaClientConfig = {
|
||||||
resolver: zodResolver(loginSchema),
|
...config,
|
||||||
defaultValues: {
|
showInDev: isDev && !config.enabled,
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <LoginForm iaaaConfig={iaaaConfig} />
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
}
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardHeader className="space-y-1">
|
|
||||||
<CardTitle className="text-2xl text-center">登录</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="id"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>用户ID</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="请输入用户ID"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>密码</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="请输入密码"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{form.formState.errors.root && (
|
|
||||||
<div className="text-sm text-red-600 text-center">
|
|
||||||
{form.formState.errors.root.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
{form.formState.isSubmitting ? "登录中..." : "登录"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
93
src/app/api/auth/iaaa/callback/route.ts
Normal file
93
src/app/api/auth/iaaa/callback/route.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -6,6 +6,36 @@ import bcrypt from "bcryptjs"
|
|||||||
import { db } from "./db"
|
import { db } from "./db"
|
||||||
import { clearSessionInvalidation, isSessionInvalidated } from "./service/session"
|
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 = {
|
export const authOptions: NextAuthOptions = {
|
||||||
providers: [
|
providers: [
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
@@ -22,15 +52,8 @@ export const authOptions: NextAuthOptions = {
|
|||||||
try {
|
try {
|
||||||
// 查找用户
|
// 查找用户
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: {
|
where: { id: credentials.id },
|
||||||
id: credentials.id
|
include: userAuthInclude,
|
||||||
},
|
|
||||||
include: {
|
|
||||||
roles: {
|
|
||||||
include: { permissions: true }
|
|
||||||
},
|
|
||||||
dept: true
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -39,7 +62,6 @@ export const authOptions: NextAuthOptions = {
|
|||||||
|
|
||||||
// 验证密码
|
// 验证密码
|
||||||
const isPasswordValid = await bcrypt.compare(credentials.password, user.password)
|
const isPasswordValid = await bcrypt.compare(credentials.password, user.password)
|
||||||
|
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
return null
|
return null
|
||||||
@@ -53,23 +75,8 @@ export const authOptions: NextAuthOptions = {
|
|||||||
|
|
||||||
// 清除会话失效标记(用户已重新登录,获得最新权限)
|
// 清除会话失效标记(用户已重新登录,获得最新权限)
|
||||||
await clearSessionInvalidation(user.id)
|
await clearSessionInvalidation(user.id)
|
||||||
|
|
||||||
// 返回用户信息、角色和权限
|
return buildUserJwtPayload(user) as any
|
||||||
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
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auth error:", error)
|
console.error("Auth error:", error)
|
||||||
return null
|
return null
|
||||||
|
|||||||
89
src/server/service/iaaa.ts
Normal file
89
src/server/service/iaaa.ts
Normal file
@@ -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<IaaaUserInfo | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -19,9 +23,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"next-env.d.ts",
|
||||||
|
".next-prod/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user