收紧钉钉登录一次性票据安全

This commit is contained in:
WuYongJie 2026-05-25 16:20:26 +08:00
parent f90c74fe6e
commit b596c5d08b
16 changed files with 943 additions and 182 deletions

View File

@ -0,0 +1,94 @@
import { NextRequest } from 'next/server'
import { POST } from '../route'
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
update: jest.fn(),
},
dingtalkLoginToken: {
findUnique: jest.fn(),
updateMany: jest.fn(),
create: jest.fn(),
},
$transaction: jest.fn(),
},
}))
const { prisma } = require('@/lib/prisma')
describe('POST /api/dingtalk/complete-profile', () => {
beforeEach(() => {
jest.resetAllMocks()
})
it('不再接受裸 userId 完善资料', async () => {
const response = await POST(
new NextRequest('http://localhost/api/dingtalk/complete-profile', {
method: 'POST',
body: JSON.stringify({ userId: 'user-1', department: '设计部' }),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.success).toBe(false)
expect(prisma.user.update).not.toHaveBeenCalled()
})
it('凭有效 profileToken 更新部门,并为已激活用户签发登录票据', async () => {
const mockTx = {
dingtalkLoginToken: {
findUnique: jest.fn().mockResolvedValue({
id: 'profile-token-row',
purpose: 'complete_profile',
usedAt: null,
expiresAt: new Date(Date.now() + 60_000),
user: {
id: 'user-1',
email: 'user@example.com',
username: '张三',
role: 'USER',
isSuperAdmin: false,
avatar: null,
isActive: false,
},
}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
}
prisma.$transaction.mockImplementation((callback: any) => callback(mockTx))
prisma.user.update.mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
username: '张三',
realName: '张三',
avatar: null,
role: 'USER',
department: '设计部',
isActive: true,
})
prisma.dingtalkLoginToken.create.mockResolvedValue({ id: 'login-token-row' })
const response = await POST(
new NextRequest('http://localhost/api/dingtalk/complete-profile', {
method: 'POST',
body: JSON.stringify({ profileToken: 'profile-token', department: '设计部' }),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.loginToken).toEqual(expect.any(String))
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { department: '设计部' },
})
expect(prisma.dingtalkLoginToken.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 'user-1',
purpose: 'login',
}),
})
})
})

View File

@ -1,34 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import {
consumeDingtalkLoginToken,
createDingtalkLoginToken,
} from '@/lib/dingtalk-login-token'
export async function POST(request: NextRequest) {
try {
const { userId, department } = await request.json()
const { profileToken, department } = await request.json()
if (!userId || !department) {
if (!profileToken || !department) {
return NextResponse.json(
{ success: false, message: '缺少必要参数' },
{ status: 400 }
)
}
console.log('完善用户信息(选择部门):', { userId, department })
console.log('完善钉钉用户信息(选择部门)')
// 获取当前钉钉用户信息
const currentUser = await prisma.user.findUnique({
where: { id: userId }
})
const currentUser = await consumeDingtalkLoginToken(profileToken, 'complete_profile')
if (!currentUser) {
return NextResponse.json(
{ success: false, message: '用户不存在' },
{ status: 404 }
{ success: false, message: '登录凭证无效或已过期,请重新登录' },
{ status: 401 }
)
}
// 直接更新部门信息(用户名已在登录时设置)
const user = await prisma.user.update({
where: { id: userId },
where: { id: currentUser.id },
data: {
department,
}
@ -36,10 +37,15 @@ export async function POST(request: NextRequest) {
console.log('用户信息更新成功:', user.username)
const loginTokenResult = user.isActive
? await createDingtalkLoginToken(user.id, 'login')
: null
// 返回用户信息(不包含敏感信息)
return NextResponse.json({
success: true,
message: '信息完善成功',
loginToken: loginTokenResult?.token,
user: {
id: user.id,
username: user.username,

View File

@ -0,0 +1,46 @@
import { GET } from '../route'
describe('GET /api/dingtalk/config', () => {
const originalEnv = process.env
beforeEach(() => {
jest.resetModules()
process.env = { ...originalEnv }
})
afterAll(() => {
process.env = originalEnv
})
it('缺少服务端 clientId/clientSecret 时不暴露默认钉钉配置', async () => {
delete process.env.DINGTALK_CLIENT_ID
delete process.env.DINGTALK_CLIENT_SECRET
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({
enabled: false,
clientId: null,
callbackUrl: null,
})
})
it('配置完整时只返回前端需要的 clientId 和回调地址', async () => {
process.env.DINGTALK_CLIENT_ID = 'client-id'
process.env.DINGTALK_CLIENT_SECRET = 'client-secret'
process.env.DINGTALK_CALLBACK_URL = 'https://portal.example.com/dingtalk-callback'
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({
enabled: true,
clientId: 'client-id',
callbackUrl: 'https://portal.example.com/dingtalk-callback',
})
expect(JSON.stringify(data)).not.toContain('client-secret')
})
})

View File

@ -0,0 +1,6 @@
import { NextResponse } from 'next/server'
import { getDingtalkPublicConfig } from '@/lib/dingtalk-config'
export async function GET() {
return NextResponse.json(getDingtalkPublicConfig())
}

View File

@ -0,0 +1,172 @@
import { NextRequest } from 'next/server'
import { POST } from '../route'
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
dingtalkLoginToken: {
create: jest.fn(),
},
},
}))
jest.mock('bcryptjs', () => ({
hash: jest.fn().mockResolvedValue('hashed-random-password'),
}))
const { prisma } = require('@/lib/prisma')
const bcrypt = require('bcryptjs')
function mockDingtalkFetch() {
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ accessToken: 'user-access-token' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: 'app-access-token' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ unionId: 'union-1' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, result: { userid: 'dingtalk-user-1' } }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errcode: 0,
result: {
name: '张三',
email: 'zhangsan@example.com',
mobile: '13800000000',
avatar: 'https://example.com/avatar.png',
},
}),
})
}
describe('POST /api/dingtalk/login', () => {
const originalEnv = process.env
beforeEach(() => {
jest.resetAllMocks()
bcrypt.hash.mockResolvedValue('hashed-random-password')
process.env = {
...originalEnv,
DINGTALK_CLIENT_ID: 'client-id',
DINGTALK_CLIENT_SECRET: 'client-secret',
}
global.fetch = jest.fn()
})
afterAll(() => {
process.env = originalEnv
})
it('缺少钉钉配置时直接拒绝,不请求外部接口', async () => {
delete process.env.DINGTALK_CLIENT_ID
delete process.env.DINGTALK_CLIENT_SECRET
const response = await POST(
new NextRequest('http://localhost/api/dingtalk/login', {
method: 'POST',
body: JSON.stringify({ code: 'auth-code' }),
})
)
const data = await response.json()
expect(response.status).toBe(503)
expect(data.message).toContain('未配置钉钉登录')
expect(global.fetch).not.toHaveBeenCalled()
})
it('绑定同名老用户时不覆盖原密码,并返回一次性登录票据', async () => {
mockDingtalkFetch()
const existingUser = {
id: 'user-1',
email: 'old@example.com',
username: '张三',
password: 'old-password-hash',
dingtalkUserId: null,
realName: null,
avatar: null,
phone: null,
department: '设计部',
role: 'USER',
isActive: true,
}
prisma.user.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(existingUser)
prisma.user.update.mockResolvedValue({
...existingUser,
dingtalkUserId: 'dingtalk-user-1',
loginType: 'dingtalk',
realName: '张三',
})
prisma.dingtalkLoginToken.create.mockResolvedValue({ id: 'token-row' })
const response = await POST(
new NextRequest('http://localhost/api/dingtalk/login', {
method: 'POST',
body: JSON.stringify({ code: 'auth-code' }),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.loginToken).toEqual(expect.any(String))
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: expect.not.objectContaining({ password: expect.anything() }),
})
expect(bcrypt.hash).not.toHaveBeenCalled()
})
it('创建钉钉新用户时使用随机不可知密码,不写固定密码', async () => {
mockDingtalkFetch()
prisma.user.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
prisma.user.create.mockResolvedValue({
id: 'user-2',
email: 'zhangsan@example.com',
username: '张三',
realName: '张三',
avatar: 'https://example.com/avatar.png',
role: 'USER',
department: '',
isActive: false,
})
prisma.dingtalkLoginToken.create.mockResolvedValue({ id: 'profile-token-row' })
const response = await POST(
new NextRequest('http://localhost/api/dingtalk/login', {
method: 'POST',
body: JSON.stringify({ code: 'auth-code' }),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.needCompleteProfile).toBe(true)
expect(data.profileToken).toEqual(expect.any(String))
expect(bcrypt.hash).toHaveBeenCalledWith(expect.not.stringContaining('dingtalk_auto_login'), 10)
expect(prisma.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
password: 'hashed-random-password',
loginType: 'dingtalk',
dingtalkUserId: 'dingtalk-user-1',
}),
})
})
})

View File

@ -1,18 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
import { randomBytes } from 'crypto'
import { DingtalkConfigError, requireDingtalkAppConfig } from '@/lib/dingtalk-config'
import { createDingtalkLoginToken } from '@/lib/dingtalk-login-token'
// 钉钉应用配置
const DINGTALK_CONFIG: Record<string, { clientId: string; clientSecret: string }> = {
default: {
clientId: process.env.DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f',
clientSecret: process.env.DINGTALK_CLIENT_SECRET || 'qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY',
}
function getDingtalkConfig() {
return requireDingtalkAppConfig()
}
// 获取用户访问令牌
async function getUserAccessToken(code: string, company: string = 'default') {
const config = DINGTALK_CONFIG[company]
async function getUserAccessToken(code: string) {
const config = getDingtalkConfig()
const url = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'
const params = {
@ -46,8 +45,8 @@ async function getUserAccessToken(code: string, company: string = 'default') {
}
// 获取应用访问令牌
async function getAppAccessToken(company: string = 'default') {
const config = DINGTALK_CONFIG[company]
async function getAppAccessToken() {
const config = getDingtalkConfig()
const url = 'https://oapi.dingtalk.com/gettoken'
const response = await fetch(`${url}?appkey=${config.clientId}&appsecret=${config.clientSecret}`)
@ -160,12 +159,7 @@ async function getUserInfo(userId: string, appToken: string) {
// 生成随机密码
function generateRandomPassword(length: number = 12): string {
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
let password = ''
for (let i = 0; i < length; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length))
}
return password
return randomBytes(Math.max(length, 24)).toString('base64url')
}
// 生成随机邮箱
@ -176,7 +170,7 @@ function generateRandomEmail(): string {
export async function POST(request: NextRequest) {
try {
const { code, state } = await request.json()
const { code } = await request.json()
if (!code) {
return NextResponse.json(
@ -185,14 +179,10 @@ export async function POST(request: NextRequest) {
)
}
// state 用于传递前端数据(如origin),不是公司标识
// 始终使用 default 配置
const company = 'default'
console.log('开始钉钉登录流程:', { code, company })
console.log('开始钉钉登录流程')
// 1. 获取用户访问令牌
const tokenData = await getUserAccessToken(code, company)
const tokenData = await getUserAccessToken(code)
// 检查返回的数据
if (!tokenData || !tokenData.accessToken) {
@ -207,7 +197,7 @@ export async function POST(request: NextRequest) {
console.log('获取用户访问令牌成功')
// 2. 获取应用访问令牌
const appAccessToken = await getAppAccessToken(company)
const appAccessToken = await getAppAccessToken()
console.log('获取应用访问令牌成功')
// 3. 获取unionId
@ -227,7 +217,6 @@ export async function POST(request: NextRequest) {
where: { dingtalkUserId: userId }
})
let isNewUser = false
let needCompleteProfile = false
if (!user) {
@ -236,9 +225,6 @@ export async function POST(request: NextRequest) {
where: { username: userInfo.name }
})
// 使用固定密码用于钉钉自动登录
const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10)
if (existingUserByName && !existingUserByName.dingtalkUserId) {
// 找到了同名用户且未绑定钉钉,直接关联
console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username)
@ -251,7 +237,6 @@ export async function POST(request: NextRequest) {
realName: userInfo.name,
avatar: userInfo.avatar || existingUserByName.avatar,
phone: userInfo.mobile || existingUserByName.phone,
password: hashedPassword, // 更新密码为固定值
}
})
@ -268,11 +253,13 @@ export async function POST(request: NextRequest) {
emailToUse = generateRandomEmail()
}
const randomPasswordHash = await bcrypt.hash(generateRandomPassword(32), 10)
user = await prisma.user.create({
data: {
username: userInfo.name, // 直接使用钉钉真实姓名
email: emailToUse,
password: hashedPassword,
password: randomPasswordHash,
realName: userInfo.name,
avatar: userInfo.avatar || null,
phone: userInfo.mobile || null,
@ -284,7 +271,6 @@ export async function POST(request: NextRequest) {
}
})
isNewUser = true
needCompleteProfile = true // 需要选择部门
console.log('用户创建成功:', user.id)
}
@ -292,26 +278,25 @@ export async function POST(request: NextRequest) {
// 更新现有用户信息
console.log('更新现有用户:', user.username)
// 确保密码也更新为固定值,以便前端能够自动登录
const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10)
user = await prisma.user.update({
where: { id: user.id },
data: {
realName: userInfo.name,
avatar: userInfo.avatar || user.avatar,
phone: userInfo.mobile || user.phone,
password: hashedPassword, // 更新密码为固定值
}
})
}
// 如果需要完善信息(选择部门)
if (needCompleteProfile) {
const tokenResult = await createDingtalkLoginToken(user.id, 'complete_profile')
return NextResponse.json({
success: true,
needCompleteProfile: true,
message: '请选择您的部门',
profileToken: tokenResult.token,
user: {
id: user.id,
username: user.username,
@ -333,11 +318,14 @@ export async function POST(request: NextRequest) {
}, { status: 403 })
}
const tokenResult = await createDingtalkLoginToken(user.id, 'login')
// 返回用户信息(不包含敏感信息)
return NextResponse.json({
success: true,
needCompleteProfile: false,
message: '登录成功',
loginToken: tokenResult.token,
user: {
id: user.id,
username: user.username,
@ -350,6 +338,16 @@ export async function POST(request: NextRequest) {
})
} catch (error: any) {
if (error instanceof DingtalkConfigError) {
return NextResponse.json(
{
success: false,
message: '当前环境未配置钉钉登录,请联系管理员',
},
{ status: 503 }
)
}
console.error('钉钉登录失败:', error)
return NextResponse.json(
{

View File

@ -20,6 +20,13 @@ function CompleteProfileContent() {
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [userInfo, setUserInfo] = useState<any>(null)
const [profileToken, setProfileToken] = useState('')
const [logoError, setLogoError] = useState(false)
const pageClass = 'min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4'
const panelClass = 'w-full max-w-md rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6'
const inputClass = 'w-full px-4 py-3 rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:border-slate-900 dark:focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 outline-none transition-colors'
const readonlyClass = `${inputClass} bg-slate-50 dark:bg-slate-700 cursor-not-allowed`
const buttonClass = 'w-full py-3 rounded-xl bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 hover:bg-slate-800 dark:hover:bg-white font-medium transition-colors disabled:opacity-60'
// 系统设置
const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
@ -36,6 +43,7 @@ function CompleteProfileContent() {
if (response.ok) {
const settings = await response.json()
setLoginLogoUrl(settings.loginLogoUrl || defaultLogo)
setLogoError(false)
setSiteName(settings.siteName || defaultSiteName)
}
} catch (error) {
@ -70,6 +78,13 @@ function CompleteProfileContent() {
setError('缺少用户信息,请重新登录')
}
const tokenParam = searchParams.get('profileToken')
if (tokenParam) {
setProfileToken(tokenParam)
} else {
setError('缺少登录凭证,请重新登录')
}
loadSettings()
loadDepartments()
}, [searchParams])
@ -93,7 +108,7 @@ function CompleteProfileContent() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: userInfo?.id,
profileToken,
department,
}),
})
@ -134,8 +149,7 @@ function CompleteProfileContent() {
// 已激活用户:使用 NextAuth 自动登录
const result = await signIn('credentials', {
email: data.user.email,
password: 'dingtalk_auto_login',
dingtalkToken: data.loginToken,
redirect: false,
})
@ -160,8 +174,8 @@ function CompleteProfileContent() {
if (error && !userInfo) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
<div className={pageClass}>
<div className="w-full max-w-md rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6">
<div className="text-center">
<div className="mb-4">
<svg className="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -172,7 +186,7 @@ function CompleteProfileContent() {
<p className="text-slate-600 dark:text-slate-400 mb-4">{error}</p>
<button
onClick={() => router.push('/login')}
className="btn-primary"
className={buttonClass}
>
返回登录
</button>
@ -183,28 +197,35 @@ function CompleteProfileContent() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
<div className={pageClass}>
<div className="max-w-md w-full">
{/* Logo */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<Image
src={loginLogoUrl}
alt={siteName}
width={80}
height={80}
unoptimized
className="rounded-apple shadow-apple"
/>
{loginLogoUrl && !logoError ? (
<Image
src={loginLogoUrl}
alt={siteName}
width={80}
height={80}
unoptimized
className="rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm"
onError={() => setLogoError(true)}
/>
) : (
<div className="w-20 h-20 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm bg-slate-900 dark:bg-slate-700 flex items-center justify-center">
<span className="text-3xl font-bold text-white">{siteName.charAt(0) || 'F'}</span>
</div>
)}
</div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">完善个人信息</h1>
<p className="text-slate-600 dark:text-slate-400 mt-2">首次登录,请完善您的用户信息</p>
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">完善个人信息</h1>
<p className="text-slate-600 dark:text-slate-400 mt-2">首次登录,请补全部门信息</p>
</div>
{/* Form */}
<div className="card-apple p-8">
<div className={panelClass}>
{userInfo && (
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="mb-6 p-4 bg-slate-50 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3">
{userInfo.avatar && (
<Image
@ -226,13 +247,13 @@ function CompleteProfileContent() {
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm">
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-600 px-4 py-3 rounded-apple text-sm">
<div className="bg-green-50 border border-green-200 text-green-600 px-4 py-3 rounded-xl text-sm">
{success}
</div>
)}
@ -242,7 +263,7 @@ function CompleteProfileContent() {
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
用户名
</label>
<div className="input-apple bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-300 cursor-not-allowed">
<div className={readonlyClass}>
{userInfo?.username || userInfo?.realName || '加载中...'}
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">使用您的钉钉姓名作为系统用户名</p>
@ -255,7 +276,7 @@ function CompleteProfileContent() {
<select
value={department}
onChange={(e) => setDepartment(e.target.value)}
className="input-apple"
className={inputClass}
required
>
<option value="">请选择部门</option>
@ -269,7 +290,7 @@ function CompleteProfileContent() {
<button
type="submit"
className="w-full btn-primary"
className={buttonClass}
disabled={loading}
>
{loading ? '提交中...' : '完成并登录'}
@ -293,9 +314,9 @@ function CompleteProfileContent() {
export default function CompleteProfilePage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-slate-400 border-t-transparent"></div>
<p className="mt-4 text-slate-600 dark:text-slate-400">加载中...</p>
</div>
</div>

View File

@ -4,12 +4,62 @@ import { useEffect, useState, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { signIn } from 'next-auth/react'
type CallbackStatus = 'loading' | 'success' | 'error'
function CallbackShell({
status,
message,
}: {
status: CallbackStatus
message: string
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4 py-10 dark:bg-slate-950">
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-col items-center text-center">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-800">
{status === 'loading' && (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-slate-400 border-t-transparent" />
)}
{status === 'success' && (
<svg className="h-5 w-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{status === 'error' && (
<svg className="h-5 w-5 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</div>
<h2 className={`mb-2 text-lg font-semibold ${
status === 'error' ? 'text-red-600 dark:text-red-400' : 'text-slate-900 dark:text-slate-100'
}`}>
{status === 'loading' && '钉钉登录'}
{status === 'success' && '登录成功'}
{status === 'error' && '登录失败'}
</h2>
<p className="text-sm leading-6 text-slate-600 dark:text-slate-400">{message}</p>
</div>
</div>
</div>
)
}
function DingtalkCallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [status, setStatus] = useState<CallbackStatus>('loading')
const [message, setMessage] = useState('正在处理钉钉登录...')
const postToParent = (payload: Record<string, unknown>) => {
window.parent.postMessage(payload, window.location.origin)
}
useEffect(() => {
const handleDingtalkLogin = async () => {
try {
@ -54,15 +104,13 @@ function DingtalkCallbackContent() {
setTimeout(() => {
if (window.parent !== window) {
// 在 iframe 中,通知父窗口跳转
window.parent.postMessage(
{
type: 'dingtalk_need_complete_profile',
userInfo: data.user,
},
'*'
)
postToParent({
type: 'dingtalk_need_complete_profile',
userInfo: data.user,
profileToken: data.profileToken,
})
} else {
router.push(`/complete-profile?userInfo=${userInfoParam}`)
router.push(`/complete-profile?userInfo=${userInfoParam}&profileToken=${encodeURIComponent(data.profileToken || '')}`)
}
}, 1000)
return
@ -76,26 +124,23 @@ function DingtalkCallbackContent() {
// 通知父窗口登录成功(如果在 iframe 中)
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'dingtalk_login_success',
userInfo: data.user,
originUrl: originUrl // 携带原始地址
},
'*'
)
postToParent({
type: 'dingtalk_login_success',
userInfo: data.user,
loginToken: data.loginToken,
originUrl: originUrl // 携带原始地址
})
} else {
// 不在 iframe 中(直接访问回调页面)
if (isFromIntranet) {
// 从内网来的:不在这里创建session,跳转回内网后由内网页面创建
setMessage('登录成功,正在返回内网...')
// 将用户信息作为参数传递,让内网页面创建session
window.location.href = `${originUrl}/login?dingtalk_success=true&email=${encodeURIComponent(data.user.email)}`
// 将一次性登录凭证传递回内网页面,让内网页面创建 session
window.location.href = `${originUrl}/login?dingtalk_success=true&token=${encodeURIComponent(data.loginToken)}`
} else {
// 从外网来的:直接创建session并跳转
const signInResult = await signIn('credentials', {
email: data.user.email,
password: 'dingtalk_auto_login',
dingtalkToken: data.loginToken,
redirect: false,
})
@ -117,7 +162,7 @@ function DingtalkCallbackContent() {
// 3秒后关闭或跳转
setTimeout(() => {
if (window.parent !== window) {
window.parent.postMessage({ type: 'dingtalk_login_error', message: error.message }, '*')
postToParent({ type: 'dingtalk_login_error', message: error.message })
} else {
router.push('/login')
}
@ -128,61 +173,13 @@ function DingtalkCallbackContent() {
handleDingtalkLogin()
}, [searchParams, router])
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
<div className="text-center">
{status === 'loading' && (
<div className="mb-4">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
</div>
)}
{status === 'success' && (
<div className="mb-4">
<svg className="mx-auto h-12 w-12 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
{status === 'error' && (
<div className="mb-4">
<svg className="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
)}
<h2 className={`text-xl font-semibold mb-2 ${
status === 'error' ? 'text-red-600' : 'text-slate-800 dark:text-slate-200'
}`}>
{status === 'loading' && '钉钉登录'}
{status === 'success' && '登录成功'}
{status === 'error' && '登录失败'}
</h2>
<p className="text-slate-600 dark:text-slate-400">{message}</p>
</div>
</div>
</div>
)
return <CallbackShell status={status} message={message} />
}
export default function DingtalkCallbackPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
<div className="text-center">
<div className="mb-4">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
</div>
<h2 className="text-xl font-semibold mb-2 text-slate-800 dark:text-slate-200">钉钉登录</h2>
<p className="text-slate-600 dark:text-slate-400">正在加载...</p>
</div>
</div>
</div>
<CallbackShell status="loading" message="正在加载..." />
}>
<DingtalkCallbackContent />
</Suspense>

View File

@ -8,7 +8,13 @@ import Link from 'next/link'
// 默认登录页 Logo:优先使用构建时环境变量,否则使用内置静态资源
const DEFAULT_LOGIN_LOGO =
process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png'
process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''
type DingtalkPublicConfig = {
enabled: boolean
clientId: string | null
callbackUrl: string | null
}
export default function LoginPage() {
const router = useRouter()
@ -17,10 +23,16 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showDingtalkModal, setShowDingtalkModal] = useState(false)
const [dingtalkConfigLoaded, setDingtalkConfigLoaded] = useState(false)
// 系统设置
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO)
const [siteName, setSiteName] = useState('FLNXI素材库')
const [dingtalkConfig, setDingtalkConfig] = useState<DingtalkPublicConfig>({
enabled: false,
clientId: null,
callbackUrl: null,
})
// Logo 加载状态
const [logoError, setLogoError] = useState(false)
@ -54,9 +66,6 @@ export default function LoginPage() {
}
}, [loginLogoUrl])
// 钉钉配置(从环境变量或配置文件获取)
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
// 加载系统设置
useEffect(() => {
const loadSettings = async () => {
@ -74,6 +83,28 @@ export default function LoginPage() {
loadSettings()
}, [])
useEffect(() => {
const loadDingtalkConfig = async () => {
try {
const response = await fetch('/api/dingtalk/config')
if (response.ok) {
const config = await response.json()
setDingtalkConfig({
enabled: Boolean(config.enabled),
clientId: config.clientId || null,
callbackUrl: config.callbackUrl || null,
})
}
} catch (error) {
console.error('加载钉钉配置失败:', error)
} finally {
setDingtalkConfigLoaded(true)
}
}
loadDingtalkConfig()
}, [])
// 页面加载时识别访问环境
useEffect(() => {
const envDomains = (process.env.NEXT_PUBLIC_PUBLIC_DOMAINS || '').split(',').map(s => s.trim()).filter(Boolean)
@ -112,23 +143,21 @@ export default function LoginPage() {
const handleDingtalkAutoLogin = async () => {
const urlParams = new URLSearchParams(window.location.search)
const dingtalkSuccess = urlParams.get('dingtalk_success')
const userEmail = urlParams.get('email')
const loginToken = urlParams.get('token')
if (dingtalkSuccess === 'true' && userEmail) {
if (dingtalkSuccess === 'true' && loginToken) {
setLoading(true)
try {
window.history.replaceState({}, '', '/login')
// 在内网域名下创建 session
const result = await signIn('credentials', {
email: decodeURIComponent(userEmail),
password: 'dingtalk_auto_login',
dingtalkToken: decodeURIComponent(loginToken),
redirect: false,
})
if (result?.error) {
setError('登录失败,请重试')
} else {
// 清除 URL 参数并跳转到首页
window.history.replaceState({}, '', '/login')
router.push('/')
router.refresh()
}
@ -170,8 +199,13 @@ export default function LoginPage() {
}
const handleDingtalkLogin = () => {
if (!dingtalkConfig.enabled || !dingtalkConfig.clientId) {
setError('钉钉登录未配置,请联系管理员')
return
}
// 强制使用外网域名作为回调地址,支持内网访问
const redirectUrl = process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_URL || `${window.location.origin}/dingtalk-callback`
const redirectUrl = dingtalkConfig.callbackUrl || `${window.location.origin}/dingtalk-callback`
// 在state中携带原始访问地址信息
const isIntranet = window.location.hostname.includes('192.168.')
@ -180,7 +214,7 @@ export default function LoginPage() {
const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?` +
`redirect_uri=${encodeURIComponent(redirectUrl)}` +
`&client_id=${DINGTALK_CLIENT_ID}` +
`&client_id=${dingtalkConfig.clientId}` +
`&scope=openid` +
`&response_type=code` +
`&state=${encodeURIComponent(stateData)}` +
@ -207,14 +241,17 @@ export default function LoginPage() {
// 监听来自 iframe 的消息
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
if (event.origin !== window.location.origin) {
return
}
if (event.data && event.data.type === 'dingtalk_login_success') {
setShowDingtalkModal(false)
// 创建 NextAuth session
try {
const result = await signIn('credentials', {
email: event.data.userInfo.email,
password: 'dingtalk_auto_login',
dingtalkToken: event.data.loginToken,
redirect: false,
})
@ -243,7 +280,7 @@ export default function LoginPage() {
setShowDingtalkModal(false)
// 跳转到信息完善页面
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))
router.push(`/complete-profile?userInfo=${userInfoParam}`)
router.push(`/complete-profile?userInfo=${userInfoParam}&profileToken=${encodeURIComponent(event.data.profileToken || '')}`)
} else if (event.data && event.data.type === 'dingtalk_login_error') {
setShowDingtalkModal(false)
setError(event.data.message || '钉钉登录失败')
@ -255,7 +292,7 @@ export default function LoginPage() {
}, [router])
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4">
<div className="max-w-md w-full">
{/* Logo */}
<div className="text-center mb-8">
@ -268,7 +305,7 @@ export default function LoginPage() {
width={80}
height={80}
unoptimized
className="rounded-apple shadow-apple"
className="rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm"
onError={(e) => {
const img = e.target as HTMLImageElement
setLogoRetryCount((prevCount) => {
@ -291,20 +328,20 @@ export default function LoginPage() {
}}
/>
) : (
<div className="w-20 h-20 rounded-apple shadow-apple bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
<div className="w-20 h-20 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm bg-slate-900 dark:bg-slate-700 flex items-center justify-center">
<span className="text-3xl font-bold text-white">{siteName.charAt(0)}</span>
</div>
)}
</div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">{siteName}</h1>
<p className="text-slate-600 dark:text-slate-400 mt-2">欢迎回来,请登录您的账号</p>
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{siteName}</h1>
<p className="text-slate-600 dark:text-slate-400 mt-2">请登录后继续使用</p>
</div>
{/* Login Form */}
<div className="card-apple p-8">
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6">
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm">
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
@ -317,7 +354,7 @@ export default function LoginPage() {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input-apple"
className="w-full px-4 py-3 rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:border-slate-900 dark:focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 outline-none transition-colors"
placeholder="your@email.com"
required
/>
@ -331,7 +368,7 @@ export default function LoginPage() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-apple"
className="w-full px-4 py-3 rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:border-slate-900 dark:focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 outline-none transition-colors"
placeholder="••••••••"
required
/>
@ -339,7 +376,7 @@ export default function LoginPage() {
<button
type="submit"
className="w-full btn-primary"
className="w-full py-3 rounded-xl bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 hover:bg-slate-800 dark:hover:bg-white font-medium transition-colors disabled:opacity-60"
disabled={loading}
>
{loading ? '登录中...' : '登录'}
@ -350,10 +387,10 @@ export default function LoginPage() {
{/* 分隔线 */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300 dark:border-slate-600"></div>
<div className="w-full border-t border-slate-200 dark:border-slate-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">或</span>
<span className="px-2 bg-white dark:bg-slate-900 text-slate-500 dark:text-slate-400">或</span>
</div>
</div>
@ -362,12 +399,19 @@ export default function LoginPage() {
id="dingtalk-login-btn"
type="button"
onClick={handleDingtalkLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-xl shadow-sm bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
disabled={!dingtalkConfig.enabled || !dingtalkConfig.clientId}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-slate-300 dark:border-slate-700 rounded-xl bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60 transition-colors"
>
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">
<path d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z" fill="#3296FA"></path>
</svg>
<span className="text-lg font-medium text-slate-700 dark:text-slate-300">钉钉登录</span>
<span className="text-lg font-medium text-slate-700 dark:text-slate-300">
{!dingtalkConfigLoaded
? '正在检查钉钉配置'
: dingtalkConfig.enabled
? '钉钉登录'
: '钉钉登录未配置'}
</span>
</button>
</div>
@ -387,11 +431,11 @@ export default function LoginPage() {
{/* 钉钉登录 iframe 弹窗 */}
{showDingtalkModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={() => setShowDingtalkModal(false)}
>
<div
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4"
className="relative bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-lg w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-700">
@ -408,10 +452,10 @@ export default function LoginPage() {
<div className="p-4">
<iframe
id="dingtalkIframe"
className="w-full h-96 border-0 rounded-lg"
title="钉钉登录"
/>
</div>
className="w-full h-96 border-0 rounded-lg bg-white dark:bg-slate-900"
title="钉钉登录"
/>
</div>
</div>
</div>
)}

View File

@ -0,0 +1,69 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
systemSettings: {
findFirst: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
},
}))
jest.mock('@/lib/dingtalk-login-token', () => ({
consumeDingtalkLoginToken: jest.fn(),
}))
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
}))
import { authOptions } from '../auth'
import { consumeDingtalkLoginToken } from '../dingtalk-login-token'
import { prisma } from '../prisma'
const mockConsumeDingtalkLoginToken = consumeDingtalkLoginToken as jest.MockedFunction<typeof consumeDingtalkLoginToken>
function getDingtalkProvider() {
const provider = authOptions.providers.find((item: any) => item.id === 'credentials') as any
if (!provider) {
throw new Error('credentials provider missing')
}
return provider
}
describe('auth dingtalk-token provider', () => {
beforeEach(() => {
jest.clearAllMocks()
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null)
})
it('使用一次性钉钉票据登录并返回会话用户', async () => {
mockConsumeDingtalkLoginToken.mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
username: '张三',
role: 'USER',
isSuperAdmin: false,
avatar: null,
isActive: true,
})
const user = await getDingtalkProvider().options.authorize({ dingtalkToken: 'one-time-token' })
expect(mockConsumeDingtalkLoginToken).toHaveBeenCalledWith('one-time-token', 'login')
expect(user).toEqual(expect.objectContaining({
id: 'user-1',
email: 'user@example.com',
name: '张三',
role: 'USER',
}))
})
it('拒绝无效或已消费的钉钉票据', async () => {
mockConsumeDingtalkLoginToken.mockResolvedValue(null)
await expect(getDingtalkProvider().options.authorize({ dingtalkToken: 'bad-token' }))
.rejects
.toThrow('钉钉登录凭证已失效')
})
})

View File

@ -0,0 +1,92 @@
import {
consumeDingtalkLoginToken,
createDingtalkLoginToken,
} from '../dingtalk-login-token'
jest.mock('@/lib/prisma', () => ({
prisma: {
dingtalkLoginToken: {
create: jest.fn(),
},
$transaction: jest.fn(),
},
}))
const { prisma } = require('@/lib/prisma')
describe('dingtalk-login-token', () => {
beforeEach(() => {
jest.resetAllMocks()
})
it('创建票据时只保存哈希,不保存明文 token', async () => {
const token = await createDingtalkLoginToken('user-1', 'login')
expect(token.token).toEqual(expect.any(String))
expect(token.token.length).toBeGreaterThan(30)
expect(prisma.dingtalkLoginToken.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 'user-1',
purpose: 'login',
tokenHash: expect.any(String),
expiresAt: expect.any(Date),
}),
})
expect(prisma.dingtalkLoginToken.create.mock.calls[0][0].data.tokenHash).not.toBe(token.token)
})
it('消费票据时会标记已使用并返回用户', async () => {
const mockTx = {
dingtalkLoginToken: {
findUnique: jest.fn().mockResolvedValue({
id: 'token-1',
purpose: 'login',
usedAt: null,
expiresAt: new Date(Date.now() + 60_000),
user: {
id: 'user-1',
email: 'user@example.com',
username: '张三',
role: 'USER',
isSuperAdmin: false,
avatar: null,
isActive: true,
},
}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
}
prisma.$transaction.mockImplementation((callback: any) => callback(mockTx))
const user = await consumeDingtalkLoginToken('plain-token', 'login')
expect(user).toEqual(expect.objectContaining({ id: 'user-1', email: 'user@example.com' }))
expect(mockTx.dingtalkLoginToken.updateMany).toHaveBeenCalledWith({
where: expect.objectContaining({
id: 'token-1',
purpose: 'login',
usedAt: null,
}),
data: { usedAt: expect.any(Date) },
})
})
it('拒绝过期或已使用票据', async () => {
const mockTx = {
dingtalkLoginToken: {
findUnique: jest.fn().mockResolvedValue({
id: 'token-1',
purpose: 'login',
usedAt: new Date(),
expiresAt: new Date(Date.now() + 60_000),
user: { id: 'user-1' },
}),
updateMany: jest.fn(),
},
}
prisma.$transaction.mockImplementation((callback: any) => callback(mockTx))
await expect(consumeDingtalkLoginToken('plain-token', 'login')).resolves.toBeNull()
expect(mockTx.dingtalkLoginToken.updateMany).not.toHaveBeenCalled()
})
})

View File

@ -1,17 +1,42 @@
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 bcrypt from 'bcryptjs'
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) {
const dingtalkToken = credentials?.dingtalkToken?.trim()
if (dingtalkToken) {
const user = await consumeDingtalkLoginToken(dingtalkToken, 'login')
if (!user) {
throw new Error('钉钉登录凭证已失效,请重新扫码')
}
const settings = await prisma.systemSettings.findFirst()
if (settings?.enableUserActivation && !user.isActive && user.role !== 'ADMIN' && !(user as any).isSuperAdmin) {
throw new Error('账号未激活,请联系管理员')
}
return {
id: user.id,
email: user.email,
name: user.username,
role: user.role as "ADMIN" | "USER",
isSuperAdmin: (user as any).isSuperAdmin,
avatar: user.avatar,
}
}
if (!credentials?.email || !credentials?.password) {
throw new Error('请输入邮箱和密码')
}
@ -66,10 +91,10 @@ export const authOptions: NextAuthOptions = {
if (token.id && typeof token.id === 'string') {
const avatarUpdatedAt = (token as any).avatarUpdatedAt || 0;
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
const refreshInterval = 5 * 60 * 1000; // 5分钟
// 如果超过5分钟未刷新,或者手动触发更新,则从数据库获取最新信息
if (trigger === 'update' || (now - avatarUpdatedAt) > fiveMinutes) {
// 如果超过刷新间隔未刷新,或者手动触发更新,则从数据库获取最新信息
if (trigger === 'update' || (now - avatarUpdatedAt) > refreshInterval) {
try {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },

39
lib/dingtalk-config.ts Normal file
View File

@ -0,0 +1,39 @@
export class DingtalkConfigError extends Error {
constructor(message = '当前环境未配置钉钉登录') {
super(message)
this.name = 'DingtalkConfigError'
}
}
export interface DingtalkAppConfig {
clientId: string
clientSecret: string
}
function readEnv(name: string): string {
return (process.env[name] || '').trim()
}
export function getDingtalkPublicConfig() {
const clientId = readEnv('DINGTALK_CLIENT_ID')
const clientSecret = readEnv('DINGTALK_CLIENT_SECRET')
const callbackUrl =
readEnv('DINGTALK_CALLBACK_URL') || readEnv('NEXT_PUBLIC_DINGTALK_CALLBACK_URL')
return {
enabled: Boolean(clientId && clientSecret),
clientId: clientId && clientSecret ? clientId : null,
callbackUrl: callbackUrl || null,
}
}
export function requireDingtalkAppConfig(): DingtalkAppConfig {
const clientId = readEnv('DINGTALK_CLIENT_ID')
const clientSecret = readEnv('DINGTALK_CLIENT_SECRET')
if (!clientId || !clientSecret) {
throw new DingtalkConfigError()
}
return { clientId, clientSecret }
}

View File

@ -0,0 +1,97 @@
import { createHash, randomBytes } from 'crypto'
import { prisma } from '@/lib/prisma'
export const DINGTALK_LOGIN_TOKEN_TTL_MS = 5 * 60 * 1000
export const DINGTALK_PROFILE_TOKEN_TTL_MS = 15 * 60 * 1000
export type DingtalkLoginTokenPurpose = 'login' | 'complete_profile'
type DingtalkTokenUser = {
id: string
email: string
username: string
role: 'ADMIN' | 'USER'
isSuperAdmin?: boolean
avatar?: string | null
isActive: boolean
}
function hashDingtalkLoginToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export async function createDingtalkLoginToken(
userId: string,
purpose: DingtalkLoginTokenPurpose = 'login',
ttlMs = purpose === 'complete_profile'
? DINGTALK_PROFILE_TOKEN_TTL_MS
: DINGTALK_LOGIN_TOKEN_TTL_MS
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString('base64url')
const expiresAt = new Date(Date.now() + ttlMs)
await prisma.dingtalkLoginToken.create({
data: {
tokenHash: hashDingtalkLoginToken(token),
userId,
purpose,
expiresAt,
},
})
return { token, expiresAt }
}
export async function consumeDingtalkLoginToken(
token: string,
purpose: DingtalkLoginTokenPurpose = 'login'
): Promise<DingtalkTokenUser | null> {
const normalizedToken = token?.trim()
if (!normalizedToken) {
return null
}
const tokenHash = hashDingtalkLoginToken(normalizedToken)
const now = new Date()
return prisma.$transaction(async (tx) => {
const loginToken = await tx.dingtalkLoginToken.findUnique({
where: { tokenHash },
include: { user: true },
})
if (
!loginToken ||
loginToken.purpose !== purpose ||
loginToken.usedAt ||
loginToken.expiresAt <= now ||
!loginToken.user
) {
return null
}
const consumed = await tx.dingtalkLoginToken.updateMany({
where: {
id: loginToken.id,
purpose,
usedAt: null,
expiresAt: { gt: now },
},
data: { usedAt: now },
})
if (consumed.count !== 1) {
return null
}
return {
id: loginToken.user.id,
email: loginToken.user.email,
username: loginToken.user.username,
role: loginToken.user.role as 'ADMIN' | 'USER',
isSuperAdmin: loginToken.user.isSuperAdmin,
avatar: loginToken.user.avatar,
isActive: loginToken.user.isActive,
}
})
}

View File

@ -0,0 +1,38 @@
-- 钉钉登录一次性票据:替代固定自动登录密码
CREATE TABLE IF NOT EXISTS "dingtalk_login_tokens" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'login',
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dingtalk_login_tokens_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "dingtalk_login_tokens_tokenHash_key"
ON "dingtalk_login_tokens"("tokenHash");
CREATE INDEX IF NOT EXISTS "dingtalk_login_tokens_userId_idx"
ON "dingtalk_login_tokens"("userId");
CREATE INDEX IF NOT EXISTS "dingtalk_login_tokens_purpose_idx"
ON "dingtalk_login_tokens"("purpose");
CREATE INDEX IF NOT EXISTS "dingtalk_login_tokens_expiresAt_idx"
ON "dingtalk_login_tokens"("expiresAt");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'dingtalk_login_tokens_userId_fkey'
) THEN
ALTER TABLE "dingtalk_login_tokens"
ADD CONSTRAINT "dingtalk_login_tokens_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;

View File

@ -40,11 +40,28 @@ model User {
children User[] @relation("UserHierarchy")
favorites MaterialFavorite[]
browseHistory BrowseHistory[]
dingtalkLoginTokens DingtalkLoginToken[]
materialShareTokens MaterialShareToken[]
@@map("users")
}
model DingtalkLoginToken {
id String @id @default(uuid())
tokenHash String @unique
userId String
purpose String @default("login")
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([purpose])
@@index([expiresAt])
@@map("dingtalk_login_tokens")
}
model MaterialShareToken {
id String @id @default(uuid())
tokenHash String @unique