收紧钉钉登录一次性票据安全
This commit is contained in:
parent
f90c74fe6e
commit
b596c5d08b
94
app/api/dingtalk/complete-profile/__tests__/route.test.ts
Normal file
94
app/api/dingtalk/complete-profile/__tests__/route.test.ts
Normal 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',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
46
app/api/dingtalk/config/__tests__/route.test.ts
Normal file
46
app/api/dingtalk/config/__tests__/route.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
6
app/api/dingtalk/config/route.ts
Normal file
6
app/api/dingtalk/config/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDingtalkPublicConfig } from '@/lib/dingtalk-config'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(getDingtalkPublicConfig())
|
||||
}
|
||||
172
app/api/dingtalk/login/__tests__/route.test.ts
Normal file
172
app/api/dingtalk/login/__tests__/route.test.ts
Normal 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',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,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(
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
69
lib/__tests__/auth-dingtalk-token.test.ts
Normal file
69
lib/__tests__/auth-dingtalk-token.test.ts
Normal 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('钉钉登录凭证已失效')
|
||||
})
|
||||
})
|
||||
92
lib/__tests__/dingtalk-login-token.test.ts
Normal file
92
lib/__tests__/dingtalk-login-token.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
31
lib/auth.ts
31
lib/auth.ts
@ -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
39
lib/dingtalk-config.ts
Normal 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 }
|
||||
}
|
||||
97
lib/dingtalk-login-token.ts
Normal file
97
lib/dingtalk-login-token.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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 $$;
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user