182 lines
5.7 KiB
TypeScript
182 lines
5.7 KiB
TypeScript
import { NextAuthOptions } from 'next-auth'
|
||
import CredentialsProvider from 'next-auth/providers/credentials'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { consumeDingtalkLoginToken } from '@/lib/dingtalk-login-token'
|
||
import { isInitialAdminPasswordChangeRequired } from '@/lib/system-init'
|
||
import {
|
||
assertCanAttemptPasswordLogin,
|
||
recordPasswordLoginFailure,
|
||
recordPasswordLoginSuccess,
|
||
} from '@/lib/auth-rate-limit'
|
||
import bcrypt from 'bcryptjs'
|
||
|
||
type AccountState = {
|
||
isActive?: boolean | null
|
||
isSuperAdmin?: boolean | null
|
||
}
|
||
|
||
function isDisabledAccount(user: AccountState) {
|
||
return !Boolean(user.isSuperAdmin) && user.isActive === false
|
||
}
|
||
|
||
function assertAccountCanUseSession(user: AccountState) {
|
||
if (isDisabledAccount(user)) {
|
||
throw new Error('账号未激活或已被禁用,请联系管理员')
|
||
}
|
||
}
|
||
|
||
export const authOptions: NextAuthOptions = {
|
||
providers: [
|
||
CredentialsProvider({
|
||
id: 'credentials',
|
||
name: 'Credentials',
|
||
credentials: {
|
||
email: { label: 'Email', type: 'email' },
|
||
password: { label: 'Password', type: 'password' },
|
||
dingtalkToken: { label: 'Dingtalk Token', type: 'text' },
|
||
},
|
||
async authorize(credentials, request) {
|
||
const dingtalkToken = credentials?.dingtalkToken?.trim()
|
||
if (dingtalkToken) {
|
||
const user = await consumeDingtalkLoginToken(dingtalkToken, 'login')
|
||
if (!user) {
|
||
throw new Error('钉钉登录凭证已失效,请重新扫码')
|
||
}
|
||
|
||
assertAccountCanUseSession(user)
|
||
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
|
||
|
||
return {
|
||
id: user.id,
|
||
email: user.email,
|
||
name: user.username,
|
||
role: user.role as "ADMIN" | "USER",
|
||
isSuperAdmin: (user as any).isSuperAdmin,
|
||
mustChangePassword,
|
||
avatar: user.avatar,
|
||
}
|
||
}
|
||
|
||
const email = credentials?.email?.trim()
|
||
const password = credentials?.password
|
||
|
||
if (!email || !password) {
|
||
throw new Error('请输入邮箱和密码')
|
||
}
|
||
|
||
assertCanAttemptPasswordLogin(email, request)
|
||
|
||
const user = await prisma.user.findUnique({
|
||
where: { email },
|
||
})
|
||
|
||
if (!user) {
|
||
recordPasswordLoginFailure(email)
|
||
throw new Error('邮箱或密码错误')
|
||
}
|
||
|
||
const isPasswordValid = await bcrypt.compare(
|
||
password,
|
||
user.password
|
||
)
|
||
|
||
if (!isPasswordValid) {
|
||
recordPasswordLoginFailure(email)
|
||
throw new Error('邮箱或密码错误')
|
||
}
|
||
|
||
assertAccountCanUseSession(user)
|
||
recordPasswordLoginSuccess(email)
|
||
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
|
||
|
||
return {
|
||
id: user.id,
|
||
email: user.email,
|
||
name: user.username,
|
||
role: user.role as "ADMIN" | "USER",
|
||
isSuperAdmin: (user as any).isSuperAdmin,
|
||
mustChangePassword,
|
||
avatar: user.avatar,
|
||
}
|
||
},
|
||
}),
|
||
],
|
||
callbacks: {
|
||
async jwt({ token, user, trigger }) {
|
||
// 登录时,从 user 对象获取信息
|
||
if (user) {
|
||
token.role = (user as any).role;
|
||
token.id = user.id;
|
||
(token as any).isSuperAdmin = (user as any).isSuperAdmin || false;
|
||
(token as any).mustChangePassword = Boolean((user as any).mustChangePassword);
|
||
(token as any).avatar = (user as any).avatar;
|
||
(token as any).avatarUpdatedAt = Date.now();
|
||
}
|
||
|
||
// 如果 token 中有 id,从数据库刷新用户状态;账号禁用后让旧 JWT 会话失效。
|
||
// 每5分钟刷新一次,或者如果 trigger 是 'update'(手动刷新)
|
||
if (token.id && typeof token.id === 'string') {
|
||
let dbUser: {
|
||
avatar: string | null
|
||
role: string
|
||
isSuperAdmin: boolean
|
||
isActive: boolean
|
||
} | null = null
|
||
|
||
try {
|
||
dbUser = await prisma.user.findUnique({
|
||
where: { id: token.id as string },
|
||
select: {
|
||
avatar: true,
|
||
role: true,
|
||
isSuperAdmin: true,
|
||
isActive: true,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
// 数据库短暂异常时继续使用旧 token,避免把可恢复故障放大成全员登出。
|
||
console.error('[Auth] Failed to refresh user info:', error);
|
||
return token;
|
||
}
|
||
|
||
if (!dbUser || isDisabledAccount(dbUser)) {
|
||
throw new Error('账号不存在或已被禁用,请重新登录');
|
||
}
|
||
|
||
const avatarUpdatedAt = (token as any).avatarUpdatedAt || 0;
|
||
const now = Date.now();
|
||
const refreshInterval = 5 * 60 * 1000; // 5分钟
|
||
|
||
(token as any).role = dbUser.role;
|
||
(token as any).isSuperAdmin = dbUser.isSuperAdmin || false;
|
||
(token as any).mustChangePassword = await isInitialAdminPasswordChangeRequired(token.id as string);
|
||
|
||
if (trigger === 'update' || (now - avatarUpdatedAt) > refreshInterval) {
|
||
(token as any).avatar = dbUser.avatar;
|
||
(token as any).avatarUpdatedAt = now;
|
||
}
|
||
}
|
||
|
||
return token;
|
||
},
|
||
async session({ session, token }) {
|
||
if (session?.user && token) {
|
||
(session.user as any).role = token.role || 'USER';
|
||
(session.user as any).id = token.id;
|
||
(session.user as any).isSuperAdmin = (token as any).isSuperAdmin || false;
|
||
(session.user as any).mustChangePassword = Boolean((token as any).mustChangePassword);
|
||
(session.user as any).avatar = (token as any).avatar;
|
||
}
|
||
return session;
|
||
},
|
||
},
|
||
pages: {
|
||
signIn: '/login',
|
||
signOut: '/login',
|
||
},
|
||
session: {
|
||
strategy: 'jwt',
|
||
},
|
||
secret: process.env.NEXTAUTH_SECRET,
|
||
}
|