flnxi-Wu/lib/auth.ts
2026-06-02 10:16:28 +08:00

182 lines
5.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}