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:
zhangheng
2026-07-06 18:09:30 +08:00
commit 37f10118d3
33 changed files with 3058 additions and 0 deletions

View 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 }
)
}
}

View 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 }
)
}
}

View 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
View 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 || '登录失败' })
}
}

View 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 })
}