gongxiangp2/lib/auth.ts

233 lines
9.1 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 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,
}