233 lines
9.1 KiB
TypeScript
233 lines
9.1 KiB
TypeScript
import { NextAuthOptions } from 'next-auth'
|
||
import CredentialsProvider from 'next-auth/providers/credentials'
|
||
import { prisma } from '@/lib/prisma'
|
||
import bcrypt from 'bcryptjs'
|
||
import { resolveSecureAuthCookieForCurrentRequest } from '@/lib/auth-cookie'
|
||
import { authorizeDingtalkH5UserForNextAuth } from '@/lib/dingtalk-login'
|
||
import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access'
|
||
import { resolveUserVideoEarlyAccess } from '@/lib/user-permission-inherit'
|
||
|
||
const LEGACY_DINGTALK_AUTO_LOGIN_PASSWORD = 'dingtalk_auto_login'
|
||
|
||
export const authOptions: NextAuthOptions = {
|
||
providers: [
|
||
CredentialsProvider({
|
||
name: 'Credentials',
|
||
credentials: {
|
||
email: { label: 'Email', type: 'email' },
|
||
password: { label: 'Password', type: 'password' },
|
||
loginType: { label: 'Login Type', type: 'text' },
|
||
authCode: { label: 'Auth Code', type: 'text' },
|
||
},
|
||
async authorize(credentials) {
|
||
const input = credentials as Record<string, string> | undefined
|
||
|
||
if (input?.loginType === 'dingtalk_inapp') {
|
||
const authCode = String(input.authCode || '').trim()
|
||
if (!authCode) {
|
||
throw new Error('钉钉免登授权码不能为空')
|
||
}
|
||
|
||
const user = await authorizeDingtalkH5UserForNextAuth(authCode, {
|
||
appSource: 'workNotice',
|
||
})
|
||
|
||
return {
|
||
id: user.id,
|
||
email: user.email,
|
||
name: user.username,
|
||
role: user.role,
|
||
department: user.department,
|
||
managedDepartment: user.managedDepartment,
|
||
avatar: user.avatar,
|
||
isSuperAdmin: user.isSuperAdmin ?? false,
|
||
}
|
||
}
|
||
|
||
if (!credentials?.email || !credentials?.password) {
|
||
throw new Error('请输入邮箱和密码')
|
||
}
|
||
|
||
if (credentials.password === LEGACY_DINGTALK_AUTO_LOGIN_PASSWORD) {
|
||
throw new Error('邮箱或密码错误')
|
||
}
|
||
|
||
let user = await prisma.user.findUnique({
|
||
where: { email: credentials.email },
|
||
})
|
||
|
||
if (!user) {
|
||
throw new Error('用户不存在')
|
||
}
|
||
|
||
const isPasswordValid = await bcrypt.compare(
|
||
credentials.password,
|
||
user.password
|
||
)
|
||
|
||
if (!isPasswordValid) {
|
||
throw new Error('密码错误')
|
||
}
|
||
|
||
// 检查用户状态(优先使用 status 字段,兼容 isActive)
|
||
let userStatus = (user as any).status || (user.isActive ? 'active' : 'pending');
|
||
let isUserActive = userStatus === 'active';
|
||
|
||
// 自动人员规则:花名册在册且在职 → 自动激活;并按 M/P 等级自动同步角色
|
||
if (!isUserActive) {
|
||
try {
|
||
const { autoActivateUserIfEligible, autoSyncRoleFromPersonnel } = await import('@/lib/auto-personnel-rules');
|
||
const activated = await autoActivateUserIfEligible(user.id);
|
||
await autoSyncRoleFromPersonnel(user.id);
|
||
if (activated) {
|
||
// 刷新内存中的状态
|
||
const refreshed = await prisma.user.findUnique({ where: { id: user.id } });
|
||
if (refreshed) {
|
||
user = { ...user, ...refreshed };
|
||
userStatus = refreshed.status || (refreshed.isActive ? 'active' : 'pending');
|
||
isUserActive = userStatus === 'active';
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('自动激活/角色同步失败(不阻塞登录判定):', error);
|
||
}
|
||
}
|
||
|
||
// 管理员可以登录(即使未激活),但普通用户必须激活
|
||
// 兼容所有管理员角色:ADMIN, SYSTEM_ADMIN, DEPT_ADMIN
|
||
const isAdminRole = user.role === 'ADMIN' || user.role === 'SYSTEM_ADMIN' || user.role === 'DEPT_ADMIN';
|
||
if (!isUserActive && !isAdminRole) {
|
||
throw new Error('账号未激活,请联系管理员')
|
||
}
|
||
|
||
// 检查是否被暂停或归档
|
||
if (userStatus === 'suspended' || userStatus === 'archived') {
|
||
throw new Error('账号已被暂停或归档,无法登录')
|
||
}
|
||
|
||
// 记录登录审计信息(异步,不阻塞登录)
|
||
const clientIp = (credentials as any).ip || null;
|
||
prisma.user.update({
|
||
where: { id: user.id },
|
||
data: {
|
||
lastLoginAt: new Date(),
|
||
lastLoginIp: clientIp,
|
||
loginCount: { increment: 1 },
|
||
// 双写:同时更新 status 和 isActive(如果 status 为 active)
|
||
...(isUserActive ? {
|
||
status: 'active',
|
||
isActive: true
|
||
} : {})
|
||
}
|
||
}).catch(err => {
|
||
console.error('记录登录审计信息失败:', err);
|
||
// 不阻塞登录流程
|
||
});
|
||
|
||
return {
|
||
id: user.id,
|
||
email: user.email,
|
||
name: user.username,
|
||
role: user.role,
|
||
department: user.department,
|
||
managedDepartment: user.managedDepartment,
|
||
avatar: user.avatar, // 添加头像字段
|
||
isSuperAdmin: (user as any).isSuperAdmin ?? false, // 添加超级管理员标识
|
||
}
|
||
},
|
||
}),
|
||
],
|
||
callbacks: {
|
||
async jwt({ token, user }) {
|
||
if (user) {
|
||
token.role = (user as any).role;
|
||
token.id = user.id;
|
||
token.avatar = (user as any).avatar; // 添加头像到token
|
||
token.isSuperAdmin = (user as any).isSuperAdmin; // 添加超级管理员标识到token
|
||
token.department = (user as any).department || '';
|
||
token.managedDepartment = (user as any).managedDepartment || null;
|
||
token.accountStatus = 'active';
|
||
token.authCheckedAt = Date.now();
|
||
token.canAccessBrandCenter = await resolveBrandCenterAccessForUser(user.id, {
|
||
role: (user as any).role,
|
||
isSuperAdmin: (user as any).isSuperAdmin,
|
||
}).catch((error) => {
|
||
console.error('读取品牌中心访问范围失败,已按无权限处理:', error)
|
||
return false
|
||
})
|
||
token.videoEarlyAccess = await resolveUserVideoEarlyAccess(user.id).catch(() => false)
|
||
} else if (token.id) {
|
||
const lastCheckedAt = Number((token as any).authCheckedAt || 0)
|
||
const accessClaimMissing = typeof (token as any).canAccessBrandCenter !== 'boolean'
|
||
if (accessClaimMissing || Date.now() - lastCheckedAt >= 30_000) {
|
||
const currentUser = await prisma.user.findUnique({
|
||
where: { id: String(token.id) },
|
||
select: {
|
||
role: true,
|
||
status: true,
|
||
isActive: true,
|
||
avatar: true,
|
||
department: true,
|
||
managedDepartment: true,
|
||
isSuperAdmin: true,
|
||
},
|
||
})
|
||
|
||
const currentStatus = currentUser?.status || (currentUser?.isActive ? 'active' : 'pending')
|
||
const accountDisabled = !currentUser || currentStatus === 'suspended' || currentStatus === 'archived'
|
||
;(token as any).accountStatus = accountDisabled ? 'disabled' : currentStatus
|
||
;(token as any).authCheckedAt = Date.now()
|
||
|
||
if (currentUser && !accountDisabled) {
|
||
token.role = currentUser.role
|
||
token.avatar = currentUser.avatar
|
||
token.isSuperAdmin = currentUser.isSuperAdmin
|
||
token.department = currentUser.department || ''
|
||
token.managedDepartment = currentUser.managedDepartment || null
|
||
token.videoEarlyAccess = await resolveUserVideoEarlyAccess(String(token.id)).catch(() => false)
|
||
token.canAccessBrandCenter = await resolveBrandCenterAccessForUser(String(token.id), {
|
||
role: currentUser.role,
|
||
isSuperAdmin: currentUser.isSuperAdmin,
|
||
}).catch((error) => {
|
||
console.error('刷新品牌中心访问范围失败,已按无权限处理:', error)
|
||
return false
|
||
})
|
||
} else {
|
||
token.canAccessBrandCenter = false
|
||
}
|
||
}
|
||
}
|
||
return token;
|
||
},
|
||
async session({ session, token }) {
|
||
if ((token as any).accountStatus === 'disabled') {
|
||
;(session as any).user = null
|
||
return session
|
||
}
|
||
if (session?.user && token) {
|
||
(session.user as any).role = token.role || 'USER';
|
||
(session.user as any).id = token.id;
|
||
(session.user as any).avatar = token.avatar; // 添加头像到session
|
||
(session.user as any).isSuperAdmin = token.isSuperAdmin || false; // 添加超级管理员标识到session
|
||
(session.user as any).department = (token as any).department || '';
|
||
(session.user as any).managedDepartment = (token as any).managedDepartment || null;
|
||
(session.user as any).canAccessBrandCenter = (token as any).canAccessBrandCenter === true;
|
||
(session.user as any).videoEarlyAccess = (token as any).videoEarlyAccess === true;
|
||
}
|
||
return session;
|
||
},
|
||
},
|
||
pages: {
|
||
signIn: '/login',
|
||
},
|
||
session: {
|
||
strategy: 'jwt',
|
||
// R43修复:缩短会话过期时间从30天到7天,提高安全性
|
||
maxAge: 7 * 24 * 60 * 60, // 7天
|
||
},
|
||
get useSecureCookies() {
|
||
return resolveSecureAuthCookieForCurrentRequest()
|
||
},
|
||
secret: process.env.NEXTAUTH_SECRET,
|
||
}
|