diff --git a/app/api/dingtalk/complete-profile/__tests__/route.test.ts b/app/api/dingtalk/complete-profile/__tests__/route.test.ts new file mode 100644 index 0000000..f83f9f4 --- /dev/null +++ b/app/api/dingtalk/complete-profile/__tests__/route.test.ts @@ -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', + }), + }) + }) +}) diff --git a/app/api/dingtalk/complete-profile/route.ts b/app/api/dingtalk/complete-profile/route.ts index b3bb852..aabc9be 100644 --- a/app/api/dingtalk/complete-profile/route.ts +++ b/app/api/dingtalk/complete-profile/route.ts @@ -1,45 +1,51 @@ 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, } }) 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, diff --git a/app/api/dingtalk/config/__tests__/route.test.ts b/app/api/dingtalk/config/__tests__/route.test.ts new file mode 100644 index 0000000..4f253b5 --- /dev/null +++ b/app/api/dingtalk/config/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/dingtalk/config/route.ts b/app/api/dingtalk/config/route.ts new file mode 100644 index 0000000..7d6889a --- /dev/null +++ b/app/api/dingtalk/config/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from 'next/server' +import { getDingtalkPublicConfig } from '@/lib/dingtalk-config' + +export async function GET() { + return NextResponse.json(getDingtalkPublicConfig()) +} diff --git a/app/api/dingtalk/login/__tests__/route.test.ts b/app/api/dingtalk/login/__tests__/route.test.ts new file mode 100644 index 0000000..3782e82 --- /dev/null +++ b/app/api/dingtalk/login/__tests__/route.test.ts @@ -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', + }), + }) + }) +}) diff --git a/app/api/dingtalk/login/route.ts b/app/api/dingtalk/login/route.ts index ed427d2..5486865 100644 --- a/app/api/dingtalk/login/route.ts +++ b/app/api/dingtalk/login/route.ts @@ -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 = { - 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,34 +271,32 @@ export async function POST(request: NextRequest) { } }) - isNewUser = true needCompleteProfile = true // 需要选择部门 console.log('用户创建成功:', user.id) } } else { // 更新现有用户信息 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, @@ -332,12 +317,15 @@ export async function POST(request: NextRequest) { message: '您的账号尚未激活,请联系管理员激活后再登录' }, { 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( { diff --git a/app/complete-profile/page.tsx b/app/complete-profile/page.tsx index 58939ca..a79684a 100644 --- a/app/complete-profile/page.tsx +++ b/app/complete-profile/page.tsx @@ -20,6 +20,13 @@ function CompleteProfileContent() { const [error, setError] = useState('') const [success, setSuccess] = useState('') const [userInfo, setUserInfo] = useState(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 ( -
-
+
+
@@ -172,7 +186,7 @@ function CompleteProfileContent() {

{error}

@@ -183,28 +197,35 @@ function CompleteProfileContent() { } return ( -
+
{/* Logo */}
- {siteName} + {loginLogoUrl && !logoError ? ( + {siteName} setLogoError(true)} + /> + ) : ( +
+ {siteName.charAt(0) || 'F'} +
+ )}
-

完善个人信息

-

首次登录,请完善您的用户信息

+

完善个人信息

+

首次登录,请补全部门信息

{/* Form */} -
+
{userInfo && ( -
+
{userInfo.avatar && ( {error && ( -
+
{error}
)} {success && ( -
+
{success}
)} @@ -242,7 +263,7 @@ function CompleteProfileContent() { -
+
{userInfo?.username || userInfo?.realName || '加载中...'}

使用您的钉钉姓名作为系统用户名

@@ -255,7 +276,7 @@ function CompleteProfileContent() {