chore: initial commit of sso-mock SSO login service
Next.js + Mantine mock SSO service providing account/password, phone verify-code, and Feishu QR login. Issues a one-time sso_code that the external app exchanges for user info. Includes: - Feishu callback fix: guard against duplicate/concurrent /api/login submissions of the same single-use authorization code (feishuLogin once-guard + memoized onLoginSuccess to stop effect re-fire). - ADAPTATION_GUIDE.md: how to switch from the in-memory code store to the real backend auth/index + inner_get_user_info endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
app/api/auth/verify/route.ts
Normal file
54
app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { codeStore } from '@/utils/code-store'
|
||||
|
||||
/**
|
||||
* SSO Code 验证接口
|
||||
*
|
||||
* 外部应用拿到 sso_code 后,调用此接口换取用户信息。
|
||||
* POST /api/auth/verify
|
||||
* Body: { code: string }
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { code } = await req.json()
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ error: 'code 不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
const entry = codeStore.get(code)
|
||||
if (!entry) {
|
||||
return NextResponse.json({ error: 'code 无效或已过期' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (entry.expires < Date.now()) {
|
||||
codeStore.delete(code)
|
||||
return NextResponse.json({ error: 'code 已过期' }, { status: 401 })
|
||||
}
|
||||
|
||||
// 验证通过,删除已使用的 code(一次性)
|
||||
codeStore.delete(code)
|
||||
|
||||
// 返回用户信息和 token
|
||||
const token =
|
||||
'mock_token_' +
|
||||
Math.random().toString(36).substring(2) +
|
||||
'_' +
|
||||
Date.now()
|
||||
|
||||
return NextResponse.json({
|
||||
token,
|
||||
refresh_token: 'mock_refresh_' + Date.now(),
|
||||
user: entry.user,
|
||||
permissions: [
|
||||
{ object: '/dashboard', action: 'view' },
|
||||
{ object: '/settings', action: 'view' },
|
||||
],
|
||||
})
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || '验证失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
33
app/api/front/getAbbrList/route.ts
Normal file
33
app/api/front/getAbbrList/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
const SHARED_API = (
|
||||
process.env.NEXT_PUBLIC_SHARED_API_PATH || 'http://172.16.115.31:6610'
|
||||
).replace(/\/+$/, '')
|
||||
|
||||
export async function GET() {
|
||||
const fetchUrl = `${SHARED_API}/api/v1/basis/tenant/base/listabbr`
|
||||
console.log('[sso-mock] getAbbrList:', fetchUrl)
|
||||
|
||||
try {
|
||||
const res = await fetch(fetchUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (res.status !== 200) {
|
||||
return NextResponse.json(
|
||||
{ code: 500, message: `获取租户信息失败 (${res.status})` },
|
||||
{ status: res.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: 200 })
|
||||
} catch (err: any) {
|
||||
console.error('[sso-mock] getAbbrList error:', err.message)
|
||||
return NextResponse.json(
|
||||
{ message: err?.message || '获取租户信息失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
50
app/api/getVerifyCode/route.ts
Normal file
50
app/api/getVerifyCode/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* 代理 uirefbase 的发送验证码接口
|
||||
* uirefbase 路由: POST /api/front/protect/sendVerifyCode
|
||||
* 真实后端: POST /api/v1/basis/user/phone/code
|
||||
* 与 uirefbase 的 requestBasis 保持一致:直接 fetch,无 SDK
|
||||
*/
|
||||
const SHARED_API = (
|
||||
process.env.NEXT_PUBLIC_SHARED_API_PATH || 'http://172.16.115.31:6610'
|
||||
).replace(/\/+$/, '')
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json()
|
||||
const fetchUrl = `${SHARED_API}/api/v1/basis/user/phone/code`
|
||||
console.log('[sso-mock] getVerifyCode →', fetchUrl, '| phone:', body?.phone)
|
||||
|
||||
try {
|
||||
const res = await fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
let data: any = {}
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = { message: text }
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
return NextResponse.json(
|
||||
{ code: 500, message: data?.message || data?.msg || '验证码发送失败' },
|
||||
{ status: res.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(data, { status: 200 })
|
||||
} catch (err: any) {
|
||||
console.error('[sso-mock] getVerifyCode error:', err.message)
|
||||
return NextResponse.json(
|
||||
{ code: 500, message: err?.message || '验证码发送失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
125
app/api/login/route.ts
Normal file
125
app/api/login/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { codeStore } from '@/utils/code-store'
|
||||
|
||||
const CODE_CRYPTO_KEY = 'k#8Mp$2Qr&9Tz@4W'
|
||||
|
||||
const SHARED_API = (
|
||||
process.env.NEXT_PUBLIC_SHARED_API_PATH || 'http://172.16.115.31:6610'
|
||||
).replace(/\/+$/, '')
|
||||
|
||||
function decrypt(encrypted: string): string {
|
||||
try {
|
||||
const bytes = CryptoJS.AES.decrypt(encrypted, CODE_CRYPTO_KEY)
|
||||
return bytes.toString(CryptoJS.enc.Utf8)
|
||||
} catch {
|
||||
return encrypted
|
||||
}
|
||||
}
|
||||
|
||||
function generateCode(): string {
|
||||
return Math.random().toString(36).substring(2, 10) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
async function callBasisApi(path: string, body: object) {
|
||||
const url = `${SHARED_API}${path}`
|
||||
console.log('[sso-mock] →', url)
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
let data: any
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`后端返回非 JSON [${res.status}]: ${text.slice(0, 200)}`)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// ─── POST /api/login ───
|
||||
// 与 uirefbase 保持一致:客户端发 JSON.stringify(encrypt(...))
|
||||
// req.json() 去掉外层引号得到加密字符串,decrypt 解密得到原始 JSON
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const decryptData = await req.json() // JSON 字符串 → 去掉引号 → 加密原文
|
||||
const body = JSON.parse(decrypt(decryptData))
|
||||
|
||||
const type = body?.type
|
||||
console.log('[sso-mock] login | type:', type, '| body keys:', Object.keys(body || {}))
|
||||
|
||||
if (!type) {
|
||||
return NextResponse.json({ error: '缺少 type 字段', debug_type: typeof type })
|
||||
}
|
||||
|
||||
let res: any
|
||||
|
||||
if (type === 'account') {
|
||||
res = await callBasisApi('/api/v1/basis/login/account', {
|
||||
account: body.account,
|
||||
password: body.password,
|
||||
abbr: body.abbr,
|
||||
})
|
||||
} else if (type === 'phone') {
|
||||
res = await callBasisApi('/api/v1/basis/user/phone/login', {
|
||||
phone: body.phone,
|
||||
verify_code: body.verify_code,
|
||||
tntkey: body.tntkey,
|
||||
})
|
||||
} else if (type === 'feishu') {
|
||||
res = await callBasisApi('/api/v1/basis/login/feishu', {
|
||||
code: body.code,
|
||||
redirect_uri: body.redirect_uri,
|
||||
tenant_abbr: body.tenant,
|
||||
})
|
||||
} else {
|
||||
console.error('[sso-mock] 未知 type:', type)
|
||||
return NextResponse.json({ error: `不支持的登录方式: ${type}` })
|
||||
}
|
||||
|
||||
console.log('[sso-mock] backend res.code:', res?.code)
|
||||
|
||||
// 后端业务码非 200 视为失败,直接返回错误,不生成 code
|
||||
if (res?.code !== 200) {
|
||||
const message = res?.message || res?.msg || '登录失败'
|
||||
console.log('[sso-mock] backend error:', message)
|
||||
return NextResponse.json({ error: message })
|
||||
}
|
||||
|
||||
const user = res.data?.user
|
||||
if (!user || !user.account) {
|
||||
console.log('[sso-mock] backend returned 200 but no valid user')
|
||||
return NextResponse.json({ error: '后端未返回有效用户信息' })
|
||||
}
|
||||
|
||||
const tenant = user?.tntkey || body.abbr || 'coowa'
|
||||
const user_info = user
|
||||
const rbac = res?.data?.rbac || { data: [] }
|
||||
const token = res.data?.access_token || ''
|
||||
|
||||
const responseData: any = { tenant, user_info, rbac, token }
|
||||
|
||||
// SSO 模式:仅当 client_id + client_redirect_uri 都存在时生成 code
|
||||
const client_id = body.client_id
|
||||
const client_redirect_uri = body.client_redirect_uri
|
||||
if (client_id && client_redirect_uri) {
|
||||
const code = generateCode()
|
||||
codeStore.set(code, {
|
||||
user: user_info,
|
||||
expires: Date.now() + 5 * 60 * 1000,
|
||||
})
|
||||
responseData.code = code
|
||||
console.log('[sso-mock] code generated →', code)
|
||||
}
|
||||
|
||||
return NextResponse.json(responseData)
|
||||
} catch (err: any) {
|
||||
console.error('[sso-mock] login error:', err.message)
|
||||
return NextResponse.json({ error: err?.message || '登录失败' })
|
||||
}
|
||||
}
|
||||
15
app/api/mock/users/route.ts
Normal file
15
app/api/mock/users/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Mock 用户列表接口(用于测试)
|
||||
* GET /api/mock/users
|
||||
*/
|
||||
export async function GET() {
|
||||
const users = [
|
||||
{ account: 'admin', password: '123456', name: '管理员', phone: '13800138000', roles: ['admin'] },
|
||||
{ account: 'user01', password: '123456', name: '测试用户', phone: '13900139000', roles: ['user'] },
|
||||
{ account: 'zhangsan', password: '123456', name: '张三', phone: '13700137000', roles: ['user', 'editor'] },
|
||||
]
|
||||
|
||||
return NextResponse.json({ users })
|
||||
}
|
||||
16
app/globals.css
Normal file
16
app/globals.css
Normal file
@@ -0,0 +1,16 @@
|
||||
html,
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
37
app/layout.tsx
Normal file
37
app/layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from 'next'
|
||||
import {
|
||||
ColorSchemeScript,
|
||||
MantineProvider,
|
||||
mantineHtmlProps,
|
||||
} from '@mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import { theme } from './theme'
|
||||
import './globals.css'
|
||||
import '@mantine/core/styles.css'
|
||||
import '@mantine/core/styles.layer.css'
|
||||
import '@mantine/notifications/styles.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Mock SSO',
|
||||
description: 'Mock SSO Login Provider',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh" {...mantineHtmlProps}>
|
||||
<head>
|
||||
<ColorSchemeScript defaultColorScheme="light" />
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider defaultColorScheme="light" theme={theme}>
|
||||
<Notifications />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
9
app/login/page.tsx
Normal file
9
app/login/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const LoginComponent = dynamic(() => import('@/components/login'), { ssr: false })
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginComponent />
|
||||
}
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function Home() {
|
||||
redirect('/login')
|
||||
}
|
||||
176
app/theme.ts
Normal file
176
app/theme.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createTheme,
|
||||
Input,
|
||||
MantineColorsTuple,
|
||||
type MantineTheme,
|
||||
} from '@mantine/core'
|
||||
|
||||
export const logoBlue = '#0080FF'
|
||||
export const logoBlueHover = '#006EE6'
|
||||
|
||||
const brandColors: MantineColorsTuple = [
|
||||
'#EAF4FF',
|
||||
'#D9EBFF',
|
||||
'#B6D8FF',
|
||||
'#8EC2FF',
|
||||
'#63AAFF',
|
||||
'#3393FF',
|
||||
logoBlue,
|
||||
logoBlueHover,
|
||||
'#005FC6',
|
||||
'#004897',
|
||||
]
|
||||
|
||||
const greyColors: MantineColorsTuple = [
|
||||
'#FFFFFF',
|
||||
'#F7F8F9',
|
||||
'#F1F2F4',
|
||||
'#DCDFE4',
|
||||
'#B5BBC2',
|
||||
'#8F979F',
|
||||
'#5D6872',
|
||||
'#47535F',
|
||||
'#1B2A38',
|
||||
'#0F151B',
|
||||
]
|
||||
|
||||
const darkColors: MantineColorsTuple = [
|
||||
'#FFFFFF',
|
||||
'#EBEEF7',
|
||||
'#D3D4D8',
|
||||
'#7E858F',
|
||||
'#3C4859',
|
||||
'#2B3648',
|
||||
'#212A39',
|
||||
'#1A222D',
|
||||
'#151C24',
|
||||
'#0D0E12',
|
||||
]
|
||||
|
||||
const successColors: MantineColorsTuple = [
|
||||
'#E6FCF5',
|
||||
'#C3FAE8',
|
||||
'#96F2D7',
|
||||
'#5BE7B6',
|
||||
'#38D9A9',
|
||||
'#20C997',
|
||||
'#0EA879',
|
||||
'#07815B',
|
||||
'#076B3F',
|
||||
'#043E22',
|
||||
]
|
||||
|
||||
export const theme = createTheme({
|
||||
primaryColor: 'brand',
|
||||
cursorType: 'pointer',
|
||||
colors: {
|
||||
brand: brandColors,
|
||||
success: successColors,
|
||||
grey: greyColors,
|
||||
dark: darkColors,
|
||||
},
|
||||
defaultRadius: 'md',
|
||||
radius: {
|
||||
xs: '2px',
|
||||
sm: '4px',
|
||||
md: '8px',
|
||||
lg: '12px',
|
||||
xl: '16px',
|
||||
xxl: '24px',
|
||||
},
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
fontSizes: {
|
||||
xs: '0.75rem',
|
||||
sm: '0.875rem',
|
||||
md: '1rem',
|
||||
lg: '1.125rem',
|
||||
xl: '1.25rem',
|
||||
xxl: '1.5rem',
|
||||
},
|
||||
headings: {
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
fontWeight: '700',
|
||||
sizes: {
|
||||
h1: { fontSize: '2.25rem', lineHeight: '1.2' },
|
||||
h2: { fontSize: '1.875rem', lineHeight: '1.3' },
|
||||
h3: { fontSize: '1.5rem', lineHeight: '1.35' },
|
||||
h4: { fontSize: '1.25rem', lineHeight: '1.4' },
|
||||
h5: { fontSize: '1.125rem', lineHeight: '1.45' },
|
||||
h6: { fontSize: '1rem', lineHeight: '1.5' },
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
xs: '8px',
|
||||
sm: '12px',
|
||||
md: '16px',
|
||||
lg: '24px',
|
||||
xl: '32px',
|
||||
xxl: '48px',
|
||||
},
|
||||
shadows: {
|
||||
xs: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
sm: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
breakpoints: {
|
||||
xs: '36em',
|
||||
sm: '48em',
|
||||
md: '62em',
|
||||
lg: '75em',
|
||||
xl: '88em',
|
||||
},
|
||||
defaultGradient: {
|
||||
from: 'brand',
|
||||
to: 'success',
|
||||
deg: 45,
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
styles: (_theme: MantineTheme, props: Record<string, unknown>) => {
|
||||
const variant = props.variant
|
||||
if (variant === 'page-ghost') {
|
||||
return {
|
||||
root: {
|
||||
outline: 'none',
|
||||
height: 30,
|
||||
borderRadius: 4,
|
||||
border: '1px solid rgba(71, 81, 105, 0.2)',
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1D2129',
|
||||
paddingInline: 16,
|
||||
'&:hover': { backgroundColor: '#F7F8FA' },
|
||||
},
|
||||
label: { fontSize: 12, lineHeight: '18px', fontWeight: 400 },
|
||||
}
|
||||
}
|
||||
if (variant === 'page-primary') {
|
||||
return {
|
||||
root: {
|
||||
outline: 'none',
|
||||
height: 30,
|
||||
borderRadius: 4,
|
||||
border: 'none',
|
||||
backgroundColor: logoBlue,
|
||||
color: '#FFFFFF',
|
||||
paddingInline: 16,
|
||||
'&:hover': { backgroundColor: logoBlueHover },
|
||||
},
|
||||
label: { fontSize: 12, lineHeight: '18px', fontWeight: 500 },
|
||||
}
|
||||
}
|
||||
return { root: { outline: 'none' } }
|
||||
},
|
||||
},
|
||||
InputWrapper: Input.Wrapper.extend({
|
||||
defaultProps: {
|
||||
inputWrapperOrder: ['label', 'input', 'description', 'error'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user