feat: 增加可信应用免登录接入
This commit is contained in:
@@ -44,6 +44,9 @@ const RetrievalLogView = defineAsyncComponent(
|
||||
const UserManagementView = defineAsyncComponent(
|
||||
() => import("./components/UserManagementView.vue"),
|
||||
);
|
||||
const SsoIntegrationView = defineAsyncComponent(
|
||||
() => import("./components/SsoIntegrationView.vue"),
|
||||
);
|
||||
|
||||
const admin = ref<AdminProfile | null>(null);
|
||||
const activeMenu = ref("dashboard");
|
||||
@@ -608,6 +611,12 @@ async function clearFeishuCache() {
|
||||
>
|
||||
系统配置
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: activeMenu === 'sso' }"
|
||||
@click="switchMenu('sso')"
|
||||
>
|
||||
应用接入
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: activeMenu === 'records' }"
|
||||
@click="switchMenu('records')"
|
||||
@@ -1003,6 +1012,8 @@ async function clearFeishuCache() {
|
||||
v-if="activeMenu === 'content-generation'"
|
||||
/>
|
||||
|
||||
<SsoIntegrationView v-if="activeMenu === 'sso'" />
|
||||
|
||||
<template v-if="activeMenu === 'configs'">
|
||||
<div class="page-head inline">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { SsoAuditItem, SsoClientItem, SsoIdentityItem } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
|
||||
const loading = ref(false);
|
||||
const savingUrl = ref(false);
|
||||
const clients = ref<SsoClientItem[]>([]);
|
||||
const userClientUrl = ref("");
|
||||
const activeTab = ref("applications");
|
||||
const clientDialogOpen = ref(false);
|
||||
const editingClientId = ref<number | null>(null);
|
||||
const secretDialogOpen = ref(false);
|
||||
const revealedSecret = ref("");
|
||||
const revealedAppId = ref("");
|
||||
|
||||
const identityItems = ref<SsoIdentityItem[]>([]);
|
||||
const identityTotal = ref(0);
|
||||
const identityPage = ref(1);
|
||||
const identityPageSize = ref(10);
|
||||
const identityKeyword = ref("");
|
||||
const identityClientId = ref<number | undefined>();
|
||||
|
||||
const auditItems = ref<SsoAuditItem[]>([]);
|
||||
const auditTotal = ref(0);
|
||||
const auditPage = ref(1);
|
||||
const auditPageSize = ref(10);
|
||||
const auditClientId = ref<number | undefined>();
|
||||
const auditStatus = ref("");
|
||||
|
||||
const clientForm = reactive({
|
||||
appId: "",
|
||||
name: "",
|
||||
redirectUrisText: "",
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => editingClientId.value ? "编辑接入应用" : "新增接入应用");
|
||||
|
||||
onMounted(loadOverview);
|
||||
|
||||
async function loadOverview() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [config, clientList] = await Promise.all([api.ssoConfig(), api.ssoClients()]);
|
||||
userClientUrl.value = config.userClientUrl;
|
||||
clients.value = clientList;
|
||||
} catch (error) {
|
||||
showError(error, "应用接入配置加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePublicUrl() {
|
||||
savingUrl.value = true;
|
||||
try {
|
||||
const result = await api.saveSsoConfig(userClientUrl.value.trim());
|
||||
userClientUrl.value = result.userClientUrl;
|
||||
ElMessage.success("用户端公网地址已保存");
|
||||
} catch (error) {
|
||||
showError(error, "用户端公网地址保存失败");
|
||||
} finally {
|
||||
savingUrl.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editingClientId.value = null;
|
||||
Object.assign(clientForm, { appId: "", name: "", redirectUrisText: "", status: 1 });
|
||||
clientDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(client: SsoClientItem) {
|
||||
editingClientId.value = client.id;
|
||||
Object.assign(clientForm, {
|
||||
appId: client.appId,
|
||||
name: client.name,
|
||||
redirectUrisText: client.redirectUris.join("\n"),
|
||||
status: client.status,
|
||||
});
|
||||
clientDialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveClient() {
|
||||
const appId = clientForm.appId.trim();
|
||||
const name = clientForm.name.trim();
|
||||
if (!appId || !name) {
|
||||
ElMessage.warning("请填写应用ID和应用名称");
|
||||
return;
|
||||
}
|
||||
const redirectUris = clientForm.redirectUrisText
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
try {
|
||||
if (editingClientId.value) {
|
||||
await api.updateSsoClient(editingClientId.value, { name, redirectUris, status: clientForm.status });
|
||||
ElMessage.success("接入应用已更新");
|
||||
} else {
|
||||
const result = await api.createSsoClient({ appId, name, redirectUris, status: clientForm.status });
|
||||
revealedSecret.value = result.clientSecret || "";
|
||||
revealedAppId.value = result.appId;
|
||||
secretDialogOpen.value = true;
|
||||
}
|
||||
clientDialogOpen.value = false;
|
||||
await loadOverview();
|
||||
} catch (error) {
|
||||
showError(error, "接入应用保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleClient(client: SsoClientItem) {
|
||||
const targetStatus = client.status;
|
||||
try {
|
||||
await api.updateSsoClient(client.id, {
|
||||
name: client.name,
|
||||
redirectUris: client.redirectUris,
|
||||
status: targetStatus,
|
||||
});
|
||||
ElMessage.success(targetStatus === 1 ? "应用已启用" : "应用已停用");
|
||||
} catch (error) {
|
||||
client.status = targetStatus === 1 ? 0 : 1;
|
||||
showError(error, "应用状态更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateSecret(client: SsoClientItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`更新后,${client.name} 使用的旧密钥会立即失效。确定继续吗?`,
|
||||
"更新应用密钥",
|
||||
{ type: "warning", confirmButtonText: "确认更新", cancelButtonText: "取消" },
|
||||
);
|
||||
const result = await api.rotateSsoClientSecret(client.id);
|
||||
revealedSecret.value = result.clientSecret;
|
||||
revealedAppId.value = client.appId;
|
||||
secretDialogOpen.value = true;
|
||||
} catch (error) {
|
||||
if (error === "cancel" || error === "close") return;
|
||||
showError(error, "应用密钥更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
await navigator.clipboard.writeText(revealedSecret.value);
|
||||
ElMessage.success("应用密钥已复制");
|
||||
}
|
||||
|
||||
async function switchTab(tabName: string | number) {
|
||||
activeTab.value = String(tabName);
|
||||
if (activeTab.value === "identities") await loadIdentities();
|
||||
if (activeTab.value === "audits") await loadAudits();
|
||||
}
|
||||
|
||||
async function loadIdentities() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await api.ssoIdentities({
|
||||
clientId: identityClientId.value,
|
||||
keyword: identityKeyword.value.trim(),
|
||||
page: identityPage.value,
|
||||
pageSize: identityPageSize.value,
|
||||
});
|
||||
identityItems.value = page.items;
|
||||
identityTotal.value = page.total;
|
||||
} catch (error) {
|
||||
showError(error, "账号绑定加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchIdentities() {
|
||||
identityPage.value = 1;
|
||||
await loadIdentities();
|
||||
}
|
||||
|
||||
async function changeIdentityPage(page: number, pageSize: number) {
|
||||
identityPage.value = page;
|
||||
identityPageSize.value = pageSize;
|
||||
await loadIdentities();
|
||||
}
|
||||
|
||||
async function unlinkIdentity(item: SsoIdentityItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`解除后,${item.userName} 下次从 ${item.clientName} 进入时需要重新核对手机号。`,
|
||||
"解除账号绑定",
|
||||
{ type: "warning", confirmButtonText: "解除绑定", cancelButtonText: "取消" },
|
||||
);
|
||||
await api.unlinkSsoIdentity(item.id);
|
||||
ElMessage.success("账号绑定已解除");
|
||||
await Promise.all([loadIdentities(), loadOverview()]);
|
||||
} catch (error) {
|
||||
if (error === "cancel" || error === "close") return;
|
||||
showError(error, "解除账号绑定失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAudits() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await api.ssoAudits({
|
||||
clientId: auditClientId.value,
|
||||
auditStatus: auditStatus.value,
|
||||
page: auditPage.value,
|
||||
pageSize: auditPageSize.value,
|
||||
});
|
||||
auditItems.value = page.items;
|
||||
auditTotal.value = page.total;
|
||||
} catch (error) {
|
||||
showError(error, "登录审计加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchAudits() {
|
||||
auditPage.value = 1;
|
||||
await loadAudits();
|
||||
}
|
||||
|
||||
async function changeAuditPage(page: number, pageSize: number) {
|
||||
auditPage.value = page;
|
||||
auditPageSize.value = pageSize;
|
||||
await loadAudits();
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2");
|
||||
}
|
||||
|
||||
function showError(error: unknown, fallback: string) {
|
||||
ElMessage.error(error instanceof Error ? error.message : fallback);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="sso-page">
|
||||
<div class="page-head inline">
|
||||
<div>
|
||||
<h2>应用接入</h2>
|
||||
<p>让已在其他可信应用登录的学员免验证码进入千问千答,并独立管理密钥、账号绑定和登录审计。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreateDialog">新增接入应用</el-button>
|
||||
</div>
|
||||
|
||||
<section class="sso-public-url">
|
||||
<div>
|
||||
<strong>用户端公网地址</strong>
|
||||
<p>服务端会使用这个地址生成免登录入口,例如 https://qa.example.com。</p>
|
||||
</div>
|
||||
<el-input v-model="userClientUrl" placeholder="https://qa.example.com" clearable />
|
||||
<el-button :loading="savingUrl" @click="savePublicUrl">保存地址</el-button>
|
||||
</section>
|
||||
|
||||
<el-tabs :model-value="activeTab" @tab-change="switchTab">
|
||||
<el-tab-pane label="接入应用" name="applications">
|
||||
<el-table :data="clients" stripe empty-text="还没有接入应用">
|
||||
<el-table-column prop="name" label="应用名称" min-width="180" />
|
||||
<el-table-column prop="appId" label="应用ID" min-width="180" />
|
||||
<el-table-column label="回跳地址" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.redirectUris.join("、") || "未配置" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="identityCount" label="绑定用户" width="100" align="center" />
|
||||
<el-table-column label="最近使用" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.lastUsedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.status" :active-value="1" :inactive-value="0" @change="toggleClient(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="sso-row-actions">
|
||||
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button size="small" @click="rotateSecret(row)">更新密钥</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="账号绑定" name="identities">
|
||||
<div class="sso-filters">
|
||||
<el-select v-model="identityClientId" clearable placeholder="全部应用">
|
||||
<el-option v-for="client in clients" :key="client.id" :label="client.name" :value="client.id" />
|
||||
</el-select>
|
||||
<el-input v-model="identityKeyword" clearable placeholder="搜索姓名、手机号或外部用户ID" @keyup.enter="searchIdentities" />
|
||||
<el-button type="primary" @click="searchIdentities">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="identityItems" stripe empty-text="暂无账号绑定">
|
||||
<el-table-column prop="clientName" label="来源应用" min-width="150" />
|
||||
<el-table-column prop="userName" label="学员" min-width="120" />
|
||||
<el-table-column label="手机号" width="140">
|
||||
<template #default="{ row }">{{ maskPhone(row.phone) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="externalUserId" label="外部用户ID" min-width="190" />
|
||||
<el-table-column label="最近免登录" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }"><el-button size="small" type="danger" plain @click="unlinkIdentity(row)">解除绑定</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="identityPage" :page-size="identityPageSize" :total="identityTotal" @change="changeIdentityPage" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="登录审计" name="audits">
|
||||
<div class="sso-filters">
|
||||
<el-select v-model="auditClientId" clearable placeholder="全部应用">
|
||||
<el-option v-for="client in clients" :key="client.id" :label="client.name" :value="client.id" />
|
||||
</el-select>
|
||||
<el-select v-model="auditStatus" clearable placeholder="全部状态">
|
||||
<el-option label="成功" value="SUCCESS" />
|
||||
<el-option label="失败" value="FAILED" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="searchAudits">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="auditItems" stripe empty-text="暂无登录审计">
|
||||
<el-table-column prop="clientName" label="来源应用" min-width="150" />
|
||||
<el-table-column prop="externalUserId" label="外部用户ID" min-width="190" />
|
||||
<el-table-column label="环节" width="110">
|
||||
<template #default="{ row }">{{ row.action === "ticket" ? "申请授权码" : "兑换登录态" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }"><el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'">{{ row.status === "SUCCESS" ? "成功" : "失败" }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="errorMessage" label="失败原因" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="ip" label="请求IP" width="140" />
|
||||
<el-table-column label="时间" width="180"><template #default="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="auditPage" :page-size="auditPageSize" :total="auditTotal" @change="changeAuditPage" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="clientDialogOpen" :title="dialogTitle" width="620px" destroy-on-close>
|
||||
<el-form label-position="top">
|
||||
<div class="sso-form-grid">
|
||||
<el-form-item label="应用名称"><el-input v-model="clientForm.name" placeholder="例如:学员服务 App" /></el-form-item>
|
||||
<el-form-item label="应用ID"><el-input v-model="clientForm.appId" :disabled="Boolean(editingClientId)" placeholder="例如:student-app" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="允许回跳地址">
|
||||
<el-input v-model="clientForm.redirectUrisText" type="textarea" :rows="4" placeholder="每行一个完整 HTTPS 地址;不需要回跳时可以留空" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用应用"><el-switch v-model="clientForm.status" :active-value="1" :inactive-value="0" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="clientDialogOpen = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveClient">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="secretDialogOpen" title="请立即保存应用密钥" width="620px" :close-on-click-modal="false">
|
||||
<el-alert title="密钥只在本次显示,关闭后无法再次查看。请保存到接入应用的服务端密钥管理中,不要放入前端代码。" type="warning" :closable="false" show-icon />
|
||||
<div class="sso-secret-block">
|
||||
<label>应用ID</label><code>{{ revealedAppId }}</code>
|
||||
<label>应用密钥</label><code>{{ revealedSecret }}</code>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="copySecret">复制密钥</el-button>
|
||||
<el-button type="primary" @click="secretDialogOpen = false">我已保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sso-page { display: grid; gap: 20px; }
|
||||
.sso-public-url { display: grid; grid-template-columns: minmax(250px, 1fr) minmax(320px, 1.3fr) auto; gap: 18px; align-items: center; padding: 20px 22px; border: 1px solid #dce8e4; border-radius: 16px; background: #fff; }
|
||||
.sso-public-url p { margin: 6px 0 0; color: #71817d; font-size: 13px; }
|
||||
.sso-row-actions { display: flex; justify-content: center; gap: 8px; white-space: nowrap; }
|
||||
.sso-filters { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.sso-filters .el-select { width: 190px; }
|
||||
.sso-filters .el-input { max-width: 360px; }
|
||||
.sso-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.sso-secret-block { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px 16px; align-items: start; margin-top: 20px; }
|
||||
.sso-secret-block label { color: #6c7d79; }
|
||||
.sso-secret-block code { padding: 10px 12px; overflow-wrap: anywhere; border-radius: 8px; background: #f3f7f5; color: #173d34; }
|
||||
@media (max-width: 980px) {
|
||||
.sso-public-url { grid-template-columns: 1fr; }
|
||||
.sso-filters { flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
@@ -38,6 +38,9 @@ import type {
|
||||
PromptHistoryItem,
|
||||
QuestionInsightRefreshResult,
|
||||
QuestionInsightSummary,
|
||||
SsoAuditItem,
|
||||
SsoClientItem,
|
||||
SsoIdentityItem,
|
||||
TopicSessionRecord,
|
||||
TopicSummaryRecord,
|
||||
} from "../types/api";
|
||||
@@ -263,6 +266,27 @@ export const api = {
|
||||
configs: () => request<SystemConfigItem[]>("/admin/config"),
|
||||
saveConfig: (payload: Record<string, unknown>) =>
|
||||
request<SystemConfigItem>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
|
||||
ssoConfig: () => request<{ userClientUrl: string }>("/admin/sso/config"),
|
||||
saveSsoConfig: (userClientUrl: string) =>
|
||||
request<{ userClientUrl: string }>("/admin/sso/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ userClientUrl }),
|
||||
}),
|
||||
ssoClients: () => request<SsoClientItem[]>("/admin/sso/client/list"),
|
||||
createSsoClient: (payload: { appId: string; name: string; redirectUris: string[]; status: number }) =>
|
||||
request<SsoClientItem>("/admin/sso/client", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateSsoClient: (id: number, payload: { name: string; redirectUris: string[]; status: number }) =>
|
||||
request<SsoClientItem>(`/admin/sso/client/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||
rotateSsoClientSecret: (id: number) =>
|
||||
request<{ clientId: number; clientSecret: string }>(`/admin/sso/client/${id}/secret/rotate`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
ssoIdentities: (query: { clientId?: number; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PageResult<SsoIdentityItem>>(`/admin/sso/identity/list${queryString(query)}`),
|
||||
unlinkSsoIdentity: (id: number) => request<null>(`/admin/sso/identity/${id}`, { method: "DELETE" }),
|
||||
ssoAudits: (query: { clientId?: number; auditStatus?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PageResult<SsoAuditItem>>(`/admin/sso/audit/list${queryString(query)}`),
|
||||
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
|
||||
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number } = {}) =>
|
||||
request<ChatDetail>(`/admin/chat/${sessionId}${queryString(query)}`),
|
||||
|
||||
@@ -18,6 +18,46 @@ export interface AdminProfile {
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface SsoClientItem {
|
||||
id: number;
|
||||
appId: string;
|
||||
name: string;
|
||||
redirectUris: string[];
|
||||
status: number;
|
||||
identityCount: number;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
clientSecret?: string;
|
||||
}
|
||||
|
||||
export interface SsoIdentityItem {
|
||||
id: number;
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
appId: string;
|
||||
userId: number;
|
||||
userName: string;
|
||||
phone: string;
|
||||
externalUserId: string;
|
||||
displayNameSnapshot?: string | null;
|
||||
lastLoginAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SsoAuditItem {
|
||||
id: number;
|
||||
appId?: string | null;
|
||||
clientName?: string | null;
|
||||
userId?: number | null;
|
||||
externalUserId?: string | null;
|
||||
action: string;
|
||||
status: string;
|
||||
errorMessage?: string | null;
|
||||
ip?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
userCount: number;
|
||||
sessionCount: number;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""add trusted application sso integration
|
||||
|
||||
Revision ID: 0029_sso_integration
|
||||
Revises: 0028_chat_concurrency
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0029_sso_integration"
|
||||
down_revision = "0028_chat_concurrency"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sys_sso_client",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("app_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("client_secret", sa.Text(), nullable=False),
|
||||
sa.Column("redirect_uris", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_sso_client_app_id", "sys_sso_client", ["app_id"], unique=True)
|
||||
op.create_index("ix_sys_sso_client_status", "sys_sso_client", ["status"])
|
||||
|
||||
op.create_table(
|
||||
"sys_user_external_identity",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("client_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("external_user_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("phone_snapshot", sa.String(length=20), nullable=True),
|
||||
sa.Column("display_name_snapshot", sa.String(length=100), nullable=True),
|
||||
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["client_id"], ["sys_sso_client.id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
|
||||
sa.UniqueConstraint("client_id", "external_user_id", name="uq_sso_identity_client_external"),
|
||||
sa.UniqueConstraint("client_id", "user_id", name="uq_sso_identity_client_user"),
|
||||
)
|
||||
op.create_index("ix_sys_user_external_identity_client_id", "sys_user_external_identity", ["client_id"])
|
||||
op.create_index("ix_sso_identity_user", "sys_user_external_identity", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"sys_sso_login_audit",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("client_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("external_user_id", sa.String(length=120), nullable=True),
|
||||
sa.Column("action", sa.String(length=30), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("error_message", sa.String(length=500), nullable=True),
|
||||
sa.Column("ip", sa.String(length=50), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sso_audit_client_created", "sys_sso_login_audit", ["client_id", "created_at"])
|
||||
op.create_index("ix_sso_audit_user_created", "sys_sso_login_audit", ["user_id", "created_at"])
|
||||
op.create_index("ix_sso_audit_status_created", "sys_sso_login_audit", ["status", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("sys_sso_login_audit")
|
||||
op.drop_table("sys_user_external_identity")
|
||||
op.drop_table("sys_sso_client")
|
||||
305
ai_knowledge_base_v2/apps/backend/app/api/admin_sso.py
Normal file
305
ai_knowledge_base_v2/apps/backend/app/api/admin_sso.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.pagination import page_result
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.sso import SsoClientSaveRequest, SsoClientUpdateRequest, SsoPublicConfigRequest
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.secret_service import SecretService
|
||||
from app.services.sso_service import _json_list
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/sso/config")
|
||||
def get_sso_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
value = db.scalar(
|
||||
select(SystemConfig.config_value).where(SystemConfig.config_key == "sso_user_client_url")
|
||||
)
|
||||
return api_success({"userClientUrl": value or ""})
|
||||
|
||||
|
||||
@router.put("/sso/config")
|
||||
def save_sso_config(
|
||||
payload: SsoPublicConfigRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
config = db.scalar(
|
||||
select(SystemConfig).where(SystemConfig.config_key == "sso_user_client_url")
|
||||
)
|
||||
if config is None:
|
||||
config = SystemConfig(
|
||||
config_key="sso_user_client_url",
|
||||
config_value=payload.userClientUrl,
|
||||
description="其他应用完成免登录后进入的千问千答用户端公网地址。",
|
||||
updated_by=current_admin.id,
|
||||
)
|
||||
db.add(config)
|
||||
else:
|
||||
config.config_value = payload.userClientUrl
|
||||
config.updated_by = current_admin.id
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="sso",
|
||||
action="save_public_url",
|
||||
target_id=config.id,
|
||||
)
|
||||
db.commit()
|
||||
return api_success({"userClientUrl": payload.userClientUrl})
|
||||
|
||||
|
||||
@router.get("/sso/client/list")
|
||||
def list_sso_clients(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
identity_counts = (
|
||||
select(
|
||||
UserExternalIdentity.client_id.label("client_id"),
|
||||
func.count(UserExternalIdentity.id).label("identity_count"),
|
||||
)
|
||||
.group_by(UserExternalIdentity.client_id)
|
||||
.subquery()
|
||||
)
|
||||
rows = db.execute(
|
||||
select(SsoClient, func.coalesce(identity_counts.c.identity_count, 0))
|
||||
.outerjoin(identity_counts, identity_counts.c.client_id == SsoClient.id)
|
||||
.order_by(SsoClient.created_at.desc(), SsoClient.id.desc())
|
||||
).all()
|
||||
return api_success([_client_item(client, int(identity_count)) for client, identity_count in rows])
|
||||
|
||||
|
||||
@router.post("/sso/client")
|
||||
def create_sso_client(
|
||||
payload: SsoClientSaveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
app_id = payload.appId.strip()
|
||||
if db.scalar(select(SsoClient.id).where(SsoClient.app_id == app_id)) is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="应用ID已存在")
|
||||
plaintext_secret = secrets.token_urlsafe(32)
|
||||
client = SsoClient(
|
||||
app_id=app_id,
|
||||
name=payload.name.strip(),
|
||||
client_secret=SecretService.encrypt(plaintext_secret),
|
||||
redirect_uris=json.dumps(payload.redirectUris, ensure_ascii=False),
|
||||
status=payload.status,
|
||||
created_by=current_admin.id,
|
||||
)
|
||||
db.add(client)
|
||||
db.flush()
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="sso",
|
||||
action="create_client",
|
||||
target_id=client.id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(client)
|
||||
return api_success({**_client_item(client, 0), "clientSecret": plaintext_secret})
|
||||
|
||||
|
||||
@router.put("/sso/client/{client_id}")
|
||||
def update_sso_client(
|
||||
client_id: int,
|
||||
payload: SsoClientUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
client = _require_client(db, client_id)
|
||||
client.name = payload.name.strip()
|
||||
client.redirect_uris = json.dumps(payload.redirectUris, ensure_ascii=False)
|
||||
client.status = payload.status
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="sso",
|
||||
action="update_client",
|
||||
target_id=client.id,
|
||||
)
|
||||
db.commit()
|
||||
identity_count = db.scalar(
|
||||
select(func.count(UserExternalIdentity.id)).where(UserExternalIdentity.client_id == client.id)
|
||||
) or 0
|
||||
return api_success(_client_item(client, identity_count))
|
||||
|
||||
|
||||
@router.post("/sso/client/{client_id}/secret/rotate")
|
||||
def rotate_sso_client_secret(
|
||||
client_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
client = _require_client(db, client_id)
|
||||
plaintext_secret = secrets.token_urlsafe(32)
|
||||
client.client_secret = SecretService.encrypt(plaintext_secret)
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="sso",
|
||||
action="rotate_secret",
|
||||
target_id=client.id,
|
||||
)
|
||||
db.commit()
|
||||
return api_success({"clientId": client.id, "clientSecret": plaintext_secret})
|
||||
|
||||
|
||||
@router.get("/sso/identity/list")
|
||||
def list_sso_identities(
|
||||
clientId: int | None = Query(default=None, gt=0),
|
||||
keyword: str = Query(default="", max_length=100),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=10, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
filters = []
|
||||
if clientId:
|
||||
filters.append(UserExternalIdentity.client_id == clientId)
|
||||
if keyword.strip():
|
||||
value = f"%{keyword.strip()}%"
|
||||
filters.append(
|
||||
or_(
|
||||
UserExternalIdentity.external_user_id.ilike(value),
|
||||
User.phone.ilike(value),
|
||||
User.name.ilike(value),
|
||||
SsoClient.name.ilike(value),
|
||||
)
|
||||
)
|
||||
total = db.scalar(
|
||||
select(func.count(UserExternalIdentity.id))
|
||||
.join(SsoClient, SsoClient.id == UserExternalIdentity.client_id)
|
||||
.join(User, User.id == UserExternalIdentity.user_id)
|
||||
.where(*filters)
|
||||
) or 0
|
||||
rows = db.execute(
|
||||
select(UserExternalIdentity, SsoClient, User)
|
||||
.join(SsoClient, SsoClient.id == UserExternalIdentity.client_id)
|
||||
.join(User, User.id == UserExternalIdentity.user_id)
|
||||
.where(*filters)
|
||||
.order_by(UserExternalIdentity.last_login_at.desc(), UserExternalIdentity.id.desc())
|
||||
.offset((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
).all()
|
||||
items = [_identity_item(identity, client, user) for identity, client, user in rows]
|
||||
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
@router.delete("/sso/identity/{identity_id}")
|
||||
def delete_sso_identity(
|
||||
identity_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
identity = db.get(UserExternalIdentity, identity_id)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号绑定不存在")
|
||||
db.execute(delete(UserExternalIdentity).where(UserExternalIdentity.id == identity_id))
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="sso",
|
||||
action="unlink_identity",
|
||||
target_id=identity_id,
|
||||
)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
@router.get("/sso/audit/list")
|
||||
def list_sso_audits(
|
||||
clientId: int | None = Query(default=None, gt=0),
|
||||
auditStatus: str = Query(default="", max_length=20),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=10, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
filters = []
|
||||
if clientId:
|
||||
filters.append(SsoLoginAudit.client_id == clientId)
|
||||
if auditStatus:
|
||||
filters.append(SsoLoginAudit.status == auditStatus.upper())
|
||||
total = db.scalar(select(func.count(SsoLoginAudit.id)).where(*filters)) or 0
|
||||
rows = db.execute(
|
||||
select(SsoLoginAudit, SsoClient)
|
||||
.outerjoin(SsoClient, SsoClient.id == SsoLoginAudit.client_id)
|
||||
.where(*filters)
|
||||
.order_by(SsoLoginAudit.created_at.desc(), SsoLoginAudit.id.desc())
|
||||
.offset((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
).all()
|
||||
items = [_audit_item(audit, client) for audit, client in rows]
|
||||
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
def _require_client(db: Session, client_id: int) -> SsoClient:
|
||||
client = db.get(SsoClient, client_id)
|
||||
if client is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="接入应用不存在")
|
||||
return client
|
||||
|
||||
|
||||
def _client_item(client: SsoClient, identity_count: int) -> dict:
|
||||
return {
|
||||
"id": client.id,
|
||||
"appId": client.app_id,
|
||||
"name": client.name,
|
||||
"redirectUris": _json_list(client.redirect_uris),
|
||||
"status": client.status,
|
||||
"identityCount": identity_count,
|
||||
"lastUsedAt": client.last_used_at,
|
||||
"createdAt": client.created_at,
|
||||
"updatedAt": client.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _identity_item(identity: UserExternalIdentity, client: SsoClient, user: User) -> dict:
|
||||
return {
|
||||
"id": identity.id,
|
||||
"clientId": client.id,
|
||||
"clientName": client.name,
|
||||
"appId": client.app_id,
|
||||
"userId": user.id,
|
||||
"userName": user.name,
|
||||
"phone": user.phone,
|
||||
"externalUserId": identity.external_user_id,
|
||||
"displayNameSnapshot": identity.display_name_snapshot,
|
||||
"lastLoginAt": identity.last_login_at,
|
||||
"createdAt": identity.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _audit_item(audit: SsoLoginAudit, client: SsoClient | None) -> dict:
|
||||
return {
|
||||
"id": audit.id,
|
||||
"appId": client.app_id if client else None,
|
||||
"clientName": client.name if client else None,
|
||||
"userId": audit.user_id,
|
||||
"externalUserId": audit.external_user_id,
|
||||
"action": audit.action,
|
||||
"status": audit.status,
|
||||
"errorMessage": audit.error_message,
|
||||
"ip": audit.ip,
|
||||
"createdAt": audit.created_at,
|
||||
}
|
||||
@@ -7,9 +7,11 @@ from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_token_payload
|
||||
from app.core.responses import api_success
|
||||
from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendSmsRequest
|
||||
from app.schemas.sso import SsoExchangeRequest
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.captcha_service import CaptchaService
|
||||
from app.services.security_state_service import client_ip
|
||||
from app.services.sso_service import SsoService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -31,6 +33,12 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
|
||||
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/sso/exchange")
|
||||
def exchange_sso(payload: SsoExchangeRequest, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
result = SsoService.exchange(db, code=payload.code, ip=client_ip(request))
|
||||
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
|
||||
AuthService.logout(token_payload)
|
||||
|
||||
43
ai_knowledge_base_v2/apps/backend/app/api/integration_sso.py
Normal file
43
ai_knowledge_base_v2/apps/backend/app/api/integration_sso.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.responses import api_success
|
||||
from app.schemas.sso import SsoTicketRequest
|
||||
from app.services.security_state_service import client_ip
|
||||
from app.services.sso_service import SsoService
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/ticket")
|
||||
async def create_ticket(request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
raw_body = await request.body()
|
||||
try:
|
||||
payload = SsoTicketRequest.model_validate_json(raw_body)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="接入用户参数不完整") from exc
|
||||
|
||||
app_id = request.headers.get("X-App-Id", "").strip()
|
||||
timestamp = request.headers.get("X-Timestamp", "").strip()
|
||||
nonce = request.headers.get("X-Nonce", "").strip()
|
||||
signature = request.headers.get("X-Signature", "").strip()
|
||||
if not all((app_id, timestamp, nonce, signature)):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少应用签名信息")
|
||||
|
||||
return api_success(
|
||||
SsoService.issue_ticket(
|
||||
db,
|
||||
app_id=app_id,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
signature=signature,
|
||||
raw_body=raw_body,
|
||||
payload=payload,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
)
|
||||
@@ -12,16 +12,19 @@ from app.api import (
|
||||
admin_knowledge_lifecycle,
|
||||
admin_records,
|
||||
admin_settings,
|
||||
admin_sso,
|
||||
admin_users,
|
||||
auth,
|
||||
chat,
|
||||
health,
|
||||
integration_sso,
|
||||
user,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(integration_sso.router, prefix="/integration/sso", tags=["integration-sso"])
|
||||
api_router.include_router(user.router, prefix="/user", tags=["user"])
|
||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
|
||||
@@ -33,4 +36,5 @@ api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])
|
||||
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"])
|
||||
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.models.knowledge import (
|
||||
UserKnowledgePermission,
|
||||
)
|
||||
from app.models.logs import AiRequestLog, LogRetentionPolicy, OperationLog, StorageSnapshot
|
||||
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
@@ -53,6 +54,8 @@ __all__ = [
|
||||
"PeriodicReport",
|
||||
"LogRetentionPolicy",
|
||||
"StorageSnapshot",
|
||||
"SsoClient",
|
||||
"SsoLoginAudit",
|
||||
"TopicSession",
|
||||
"TopicSummary",
|
||||
"Prompt",
|
||||
@@ -62,6 +65,7 @@ __all__ = [
|
||||
"ShareDraft",
|
||||
"TeacherHelpCard",
|
||||
"User",
|
||||
"UserExternalIdentity",
|
||||
"UserEntitlement",
|
||||
"UserEntitlementLog",
|
||||
"UserGrowthProfile",
|
||||
|
||||
75
ai_knowledge_base_v2/apps/backend/app/models/sso.py
Normal file
75
ai_knowledge_base_v2/apps/backend/app/models/sso.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class SsoClient(Base):
|
||||
__tablename__ = "sys_sso_client"
|
||||
__table_args__ = (Index("ix_sys_sso_client_status", "status"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
app_id: Mapped[str] = mapped_column(String(80), unique=True, index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
client_secret: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
redirect_uris: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
|
||||
status: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class UserExternalIdentity(Base):
|
||||
__tablename__ = "sys_user_external_identity"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_id", "external_user_id", name="uq_sso_identity_client_external"),
|
||||
UniqueConstraint("client_id", "user_id", name="uq_sso_identity_client_user"),
|
||||
Index("ix_sso_identity_user", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("sys_sso_client.id"), index=True, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), nullable=False)
|
||||
external_user_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
phone_snapshot: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
display_name_snapshot: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class SsoLoginAudit(Base):
|
||||
__tablename__ = "sys_sso_login_audit"
|
||||
__table_args__ = (
|
||||
Index("ix_sso_audit_client_created", "client_id", "created_at"),
|
||||
Index("ix_sso_audit_user_created", "user_id", "created_at"),
|
||||
Index("ix_sso_audit_status_created", "status", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
client_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
external_user_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
@@ -22,6 +22,7 @@ class LoginResponse(BaseModel):
|
||||
token: str
|
||||
expiredAt: datetime
|
||||
user: UserProfile
|
||||
returnUrl: str | None = None
|
||||
|
||||
|
||||
class CaptchaResponse(BaseModel):
|
||||
|
||||
99
ai_knowledge_base_v2/apps/backend/app/schemas/sso.py
Normal file
99
ai_knowledge_base_v2/apps/backend/app/schemas/sso.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class SsoTicketRequest(BaseModel):
|
||||
externalUserId: str = Field(min_length=1, max_length=120)
|
||||
verifiedPhone: str | None = Field(default=None, min_length=11, max_length=20)
|
||||
displayName: str | None = Field(default=None, max_length=100)
|
||||
returnUrl: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class SsoExchangeRequest(BaseModel):
|
||||
code: str = Field(min_length=20, max_length=200)
|
||||
|
||||
|
||||
class SsoClientSaveRequest(BaseModel):
|
||||
appId: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]+$")
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
redirectUris: list[str] = Field(default_factory=list, max_length=20)
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
|
||||
@field_validator("redirectUris")
|
||||
@classmethod
|
||||
def validate_redirect_uris(cls, values: list[str]) -> list[str]:
|
||||
cleaned: list[str] = []
|
||||
for raw in values:
|
||||
value = raw.strip()
|
||||
if not value:
|
||||
continue
|
||||
if not value.startswith(("https://", "http://localhost", "http://127.0.0.1")):
|
||||
raise ValueError("回跳地址必须使用 HTTPS;本地调试可使用 localhost 或 127.0.0.1")
|
||||
if value not in cleaned:
|
||||
cleaned.append(value)
|
||||
return cleaned
|
||||
|
||||
|
||||
class SsoClientUpdateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
redirectUris: list[str] = Field(default_factory=list, max_length=20)
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
|
||||
@field_validator("redirectUris")
|
||||
@classmethod
|
||||
def validate_redirect_uris(cls, values: list[str]) -> list[str]:
|
||||
return SsoClientSaveRequest.validate_redirect_uris(values)
|
||||
|
||||
|
||||
class SsoPublicConfigRequest(BaseModel):
|
||||
userClientUrl: str = Field(default="", max_length=1000)
|
||||
|
||||
@field_validator("userClientUrl")
|
||||
@classmethod
|
||||
def validate_user_client_url(cls, value: str) -> str:
|
||||
cleaned = value.strip().rstrip("/")
|
||||
if cleaned and not cleaned.startswith(("https://", "http://localhost", "http://127.0.0.1")):
|
||||
raise ValueError("用户端公网地址必须使用 HTTPS;本地调试可使用 localhost 或 127.0.0.1")
|
||||
return cleaned
|
||||
|
||||
|
||||
class SsoClientItem(BaseModel):
|
||||
id: int
|
||||
appId: str
|
||||
name: str
|
||||
redirectUris: list[str]
|
||||
status: int
|
||||
identityCount: int
|
||||
lastUsedAt: datetime | None = None
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
|
||||
|
||||
class SsoIdentityItem(BaseModel):
|
||||
id: int
|
||||
clientId: int
|
||||
clientName: str
|
||||
appId: str
|
||||
userId: int
|
||||
userName: str
|
||||
phone: str
|
||||
externalUserId: str
|
||||
displayNameSnapshot: str | None = None
|
||||
lastLoginAt: datetime | None = None
|
||||
createdAt: datetime
|
||||
|
||||
|
||||
class SsoAuditItem(BaseModel):
|
||||
id: int
|
||||
appId: str | None = None
|
||||
clientName: str | None = None
|
||||
userId: int | None = None
|
||||
externalUserId: str | None = None
|
||||
action: str
|
||||
status: str
|
||||
errorMessage: str | None = None
|
||||
ip: str | None = None
|
||||
createdAt: datetime
|
||||
@@ -26,7 +26,7 @@ class AuthService:
|
||||
ip: str,
|
||||
) -> None:
|
||||
user = cls._get_existing_user(db, phone)
|
||||
cls._ensure_user_can_login(user)
|
||||
cls.ensure_user_can_login(user)
|
||||
captcha_key = cls._captcha_required_key(phone)
|
||||
has_captcha = bool(captcha_id or captcha_code)
|
||||
if has_captcha:
|
||||
@@ -65,7 +65,7 @@ class AuthService:
|
||||
raise
|
||||
SecurityStateService.clear(failure_key)
|
||||
user = cls._get_existing_user(db, phone)
|
||||
cls._ensure_user_can_login(user)
|
||||
cls.ensure_user_can_login(user)
|
||||
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
user.last_login_at = now
|
||||
@@ -96,7 +96,7 @@ class AuthService:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中,请联系管理员")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_user_can_login(user: User) -> None:
|
||||
def ensure_user_can_login(user: User) -> None:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
if user.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||
|
||||
387
ai_knowledge_base_v2/apps/backend/app/services/sso_service.py
Normal file
387
ai_knowledge_base_v2/apps/backend/app/services/sso_service.py
Normal file
@@ -0,0 +1,387 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import create_access_token
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.sso import SsoTicketRequest
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
from app.services.security_state_service import SecurityStateService
|
||||
from app.services.secret_service import SecretService
|
||||
|
||||
|
||||
class SsoService:
|
||||
CODE_TTL_SECONDS = 60
|
||||
SIGNATURE_WINDOW_SECONDS = 300
|
||||
CODE_NAMESPACE = "auth:sso:code"
|
||||
NONCE_NAMESPACE = "auth:sso:nonce"
|
||||
|
||||
@classmethod
|
||||
def issue_ticket(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
app_id: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
signature: str,
|
||||
raw_body: bytes,
|
||||
payload: SsoTicketRequest,
|
||||
ip: str,
|
||||
) -> dict:
|
||||
client = db.scalar(
|
||||
select(SsoClient).where(SsoClient.app_id == app_id).with_for_update()
|
||||
)
|
||||
if client is None or client.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="接入应用不存在或已停用")
|
||||
|
||||
try:
|
||||
cls._verify_request(client, timestamp, nonce, signature, raw_body)
|
||||
cls._reserve_nonce(client, nonce)
|
||||
SecurityStateService.enforce_limit(
|
||||
f"rate:sso:ticket:{client.id}",
|
||||
limit=600,
|
||||
window_seconds=60,
|
||||
message="该应用免登录请求过于频繁,请稍后再试",
|
||||
)
|
||||
identity, user = cls._resolve_identity(db, client, payload)
|
||||
cls._validate_return_url(client, payload.returnUrl)
|
||||
AuthService.ensure_user_can_login(user)
|
||||
|
||||
now = _db_now(db)
|
||||
client.last_used_at = now
|
||||
identity.phone_snapshot = payload.verifiedPhone or identity.phone_snapshot
|
||||
identity.display_name_snapshot = payload.displayName or identity.display_name_snapshot
|
||||
db.add(
|
||||
SsoLoginAudit(
|
||||
client_id=client.id,
|
||||
user_id=user.id,
|
||||
external_user_id=payload.externalUserId,
|
||||
action="ticket",
|
||||
status="SUCCESS",
|
||||
ip=ip,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
code = cls._store_ticket(
|
||||
{
|
||||
"clientId": client.id,
|
||||
"userId": user.id,
|
||||
"identityId": identity.id,
|
||||
"externalUserId": payload.externalUserId,
|
||||
"returnUrl": payload.returnUrl,
|
||||
"issuedAt": int(time.time()),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"code": code,
|
||||
"expiresInSeconds": cls.CODE_TTL_SECONDS,
|
||||
"entryUrl": cls._entry_url(db, code),
|
||||
}
|
||||
except HTTPException as exc:
|
||||
cls.record_failure(
|
||||
db,
|
||||
client_id=client.id,
|
||||
external_user_id=payload.externalUserId,
|
||||
action="ticket",
|
||||
error_message=str(exc.detail),
|
||||
ip=ip,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
cls.record_failure(
|
||||
db,
|
||||
client_id=client.id,
|
||||
external_user_id=payload.externalUserId,
|
||||
action="ticket",
|
||||
error_message="单点登录服务暂时不可用",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="单点登录服务暂时不可用",
|
||||
) from exc
|
||||
|
||||
@classmethod
|
||||
def exchange(cls, db: Session, *, code: str, ip: str) -> dict:
|
||||
try:
|
||||
ticket = cls._consume_ticket(code)
|
||||
except HTTPException as exc:
|
||||
cls.record_failure(
|
||||
db,
|
||||
client_id=None,
|
||||
action="exchange",
|
||||
error_message=str(exc.detail),
|
||||
ip=ip,
|
||||
)
|
||||
raise
|
||||
client_id = _int_value(ticket.get("clientId"))
|
||||
user_id = _int_value(ticket.get("userId"))
|
||||
identity_id = _int_value(ticket.get("identityId"))
|
||||
external_user_id = str(ticket.get("externalUserId") or "")
|
||||
|
||||
try:
|
||||
client = db.get(SsoClient, client_id)
|
||||
identity = db.get(UserExternalIdentity, identity_id)
|
||||
user = db.get(User, user_id)
|
||||
if client is None or client.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="接入应用已停用")
|
||||
if (
|
||||
identity is None
|
||||
or identity.client_id != client.id
|
||||
or identity.user_id != user_id
|
||||
or identity.external_user_id != external_user_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="外部账号绑定已失效")
|
||||
if user is None or user.is_deleted:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||
AuthService.ensure_user_can_login(user)
|
||||
|
||||
now = _db_now(db)
|
||||
identity.last_login_at = now
|
||||
user.last_login_at = now
|
||||
db.add(
|
||||
SsoLoginAudit(
|
||||
client_id=client.id,
|
||||
user_id=user.id,
|
||||
external_user_id=external_user_id,
|
||||
action="exchange",
|
||||
status="SUCCESS",
|
||||
ip=ip,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
token, expired_at = create_access_token(str(user.id), "user")
|
||||
return {
|
||||
"token": token,
|
||||
"expiredAt": expired_at,
|
||||
"user": user,
|
||||
"returnUrl": ticket.get("returnUrl"),
|
||||
}
|
||||
except HTTPException as exc:
|
||||
cls.record_failure(
|
||||
db,
|
||||
client_id=client_id or None,
|
||||
user_id=user_id or None,
|
||||
external_user_id=external_user_id,
|
||||
action="exchange",
|
||||
error_message=str(exc.detail),
|
||||
ip=ip,
|
||||
)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def record_failure(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
client_id: int | None,
|
||||
action: str,
|
||||
error_message: str,
|
||||
ip: str,
|
||||
user_id: int | None = None,
|
||||
external_user_id: str | None = None,
|
||||
) -> None:
|
||||
db.rollback()
|
||||
db.add(
|
||||
SsoLoginAudit(
|
||||
client_id=client_id,
|
||||
user_id=user_id,
|
||||
external_user_id=external_user_id,
|
||||
action=action,
|
||||
status="FAILED",
|
||||
error_message=error_message[:500],
|
||||
ip=ip,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@classmethod
|
||||
def _verify_request(
|
||||
cls,
|
||||
client: SsoClient,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
signature: str,
|
||||
raw_body: bytes,
|
||||
) -> None:
|
||||
try:
|
||||
request_time = int(timestamp)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用时间戳无效") from exc
|
||||
if abs(int(time.time()) - request_time) > cls.SIGNATURE_WINDOW_SECONDS:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用请求已过期")
|
||||
if not 16 <= len(nonce) <= 120:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用随机数无效")
|
||||
|
||||
body_hash = hashlib.sha256(raw_body).hexdigest()
|
||||
canonical = f"{timestamp}\n{nonce}\n{body_hash}".encode("utf-8")
|
||||
secret = SecretService.decrypt(client.client_secret).encode("utf-8")
|
||||
expected = hmac.new(secret, canonical, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, signature.lower()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用签名无效")
|
||||
|
||||
@classmethod
|
||||
def _reserve_nonce(cls, client: SsoClient, nonce: str) -> None:
|
||||
redis = cls._required_redis()
|
||||
key = f"{cls.NONCE_NAMESPACE}:{client.id}:{hashlib.sha256(nonce.encode()).hexdigest()}"
|
||||
try:
|
||||
reserved = redis.set(key, "1", ex=cls.SIGNATURE_WINDOW_SECONDS, nx=True)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="单点登录服务暂时不可用",
|
||||
) from exc
|
||||
if not reserved:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="应用请求已被使用")
|
||||
|
||||
@classmethod
|
||||
def _resolve_identity(
|
||||
cls,
|
||||
db: Session,
|
||||
client: SsoClient,
|
||||
payload: SsoTicketRequest,
|
||||
) -> tuple[UserExternalIdentity, User]:
|
||||
identity = db.scalar(
|
||||
select(UserExternalIdentity).where(
|
||||
UserExternalIdentity.client_id == client.id,
|
||||
UserExternalIdentity.external_user_id == payload.externalUserId,
|
||||
)
|
||||
)
|
||||
if identity is not None:
|
||||
user = db.get(User, identity.user_id)
|
||||
if user is None or user.is_deleted:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="绑定的学员账号不存在")
|
||||
return identity, user
|
||||
|
||||
if not payload.verifiedPhone:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="首次登录必须提供已验证手机号")
|
||||
user = db.scalar(
|
||||
select(User).where(User.phone == payload.verifiedPhone, User.is_deleted == 0)
|
||||
)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中")
|
||||
existing_user_binding = db.scalar(
|
||||
select(UserExternalIdentity).where(
|
||||
UserExternalIdentity.client_id == client.id,
|
||||
UserExternalIdentity.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if existing_user_binding is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该学员已绑定其他外部账号")
|
||||
|
||||
identity = UserExternalIdentity(
|
||||
client_id=client.id,
|
||||
user_id=user.id,
|
||||
external_user_id=payload.externalUserId,
|
||||
phone_snapshot=payload.verifiedPhone,
|
||||
display_name_snapshot=payload.displayName,
|
||||
)
|
||||
db.add(identity)
|
||||
db.flush()
|
||||
return identity, user
|
||||
|
||||
@staticmethod
|
||||
def _validate_return_url(client: SsoClient, return_url: str | None) -> None:
|
||||
if not return_url:
|
||||
return
|
||||
allowed = _json_list(client.redirect_uris)
|
||||
if return_url.rstrip("/") not in {item.rstrip("/") for item in allowed}:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回跳地址不在应用白名单中")
|
||||
|
||||
@classmethod
|
||||
def _store_ticket(cls, payload: dict) -> str:
|
||||
redis = cls._required_redis()
|
||||
for _ in range(3):
|
||||
code = secrets.token_urlsafe(32)
|
||||
key = cls._code_key(code)
|
||||
try:
|
||||
stored = redis.set(
|
||||
key,
|
||||
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
||||
ex=cls.CODE_TTL_SECONDS,
|
||||
nx=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="单点登录服务暂时不可用",
|
||||
) from exc
|
||||
if stored:
|
||||
return code
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="授权码生成失败")
|
||||
|
||||
@classmethod
|
||||
def _consume_ticket(cls, code: str) -> dict:
|
||||
redis = cls._required_redis()
|
||||
key = cls._code_key(code)
|
||||
try:
|
||||
raw = redis.eval(
|
||||
"local v=redis.call('GET',KEYS[1]); if v then redis.call('DEL',KEYS[1]) end; return v",
|
||||
1,
|
||||
key,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="单点登录服务暂时不可用",
|
||||
) from exc
|
||||
if not raw:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="授权码无效、已使用或已过期")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="授权码数据无效") from exc
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _required_redis():
|
||||
redis = get_sync_redis_client()
|
||||
if redis is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="单点登录服务暂时不可用")
|
||||
return redis
|
||||
|
||||
@classmethod
|
||||
def _code_key(cls, code: str) -> str:
|
||||
return f"{cls.CODE_NAMESPACE}:{hashlib.sha256(code.encode()).hexdigest()}"
|
||||
|
||||
@staticmethod
|
||||
def _entry_url(db: Session, code: str) -> str:
|
||||
value = db.scalar(select(SystemConfig.config_value).where(SystemConfig.config_key == "sso_user_client_url"))
|
||||
base = (value or "").strip().rstrip("/")
|
||||
path = f"/?sso_code={quote(code)}"
|
||||
return f"{base}{path}" if base else path
|
||||
|
||||
|
||||
def _json_list(value: str | None) -> list[str]:
|
||||
try:
|
||||
decoded = json.loads(value or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [str(item) for item in decoded if str(item).strip()] if isinstance(decoded, list) else []
|
||||
|
||||
|
||||
def _db_now(db: Session) -> datetime:
|
||||
return db.scalar(select(func.now())) or datetime.now()
|
||||
|
||||
|
||||
def _int_value(value) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
213
ai_knowledge_base_v2/apps/backend/tests/test_sso_service.py
Normal file
213
ai_knowledge_base_v2/apps/backend/tests/test_sso_service.py
Normal file
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.models import Base
|
||||
from app.core.config import get_settings
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.sso import SsoTicketRequest
|
||||
from app.services.secret_service import SecretService
|
||||
from app.services.sso_service import SsoService
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.values: dict[str, str] = {}
|
||||
|
||||
def set(self, key: str, value: str, *, ex: int, nx: bool = False):
|
||||
if nx and key in self.values:
|
||||
return False
|
||||
self.values[key] = value
|
||||
return True
|
||||
|
||||
def eval(self, _script: str, _key_count: int, key: str):
|
||||
return self.values.pop(key, None)
|
||||
|
||||
|
||||
def _db() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def _signature(secret: str, timestamp: str, nonce: str, body: bytes) -> str:
|
||||
body_hash = hashlib.sha256(body).hexdigest()
|
||||
canonical = f"{timestamp}\n{nonce}\n{body_hash}".encode()
|
||||
return hmac.new(secret.encode(), canonical, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _seed(db: Session) -> tuple[User, SsoClient, str]:
|
||||
secret = "integration-secret"
|
||||
user = User(
|
||||
id=1,
|
||||
phone="13800138000",
|
||||
name="测试学员",
|
||||
status=1,
|
||||
daily_chat_limit=100,
|
||||
daily_chat_used=0,
|
||||
is_deleted=0,
|
||||
)
|
||||
client = SsoClient(
|
||||
id=1,
|
||||
app_id="student-app",
|
||||
name="学员应用",
|
||||
client_secret=SecretService.encrypt(secret),
|
||||
redirect_uris='["https://student.example.com/home"]',
|
||||
status=1,
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
user,
|
||||
client,
|
||||
SystemConfig(config_key="sso_user_client_url", config_value="https://qa.example.com"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
return user, client, secret
|
||||
|
||||
|
||||
def test_sso_ticket_binds_user_and_exchanges_only_once(monkeypatch: pytest.MonkeyPatch):
|
||||
redis = FakeRedis()
|
||||
monkeypatch.setattr("app.services.sso_service.get_sync_redis_client", lambda: redis)
|
||||
monkeypatch.setattr(get_settings(), "jwt_secret_key", "test-sso-jwt-secret-key-32-bytes-long")
|
||||
with _db() as db:
|
||||
user, client, secret = _seed(db)
|
||||
raw_body = json.dumps(
|
||||
{
|
||||
"externalUserId": "external-1001",
|
||||
"verifiedPhone": user.phone,
|
||||
"displayName": "外部昵称",
|
||||
"returnUrl": "https://student.example.com/home",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
payload = SsoTicketRequest.model_validate_json(raw_body)
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = secrets.token_urlsafe(24)
|
||||
|
||||
result = SsoService.issue_ticket(
|
||||
db,
|
||||
app_id=client.app_id,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
signature=_signature(secret, timestamp, nonce, raw_body),
|
||||
raw_body=raw_body,
|
||||
payload=payload,
|
||||
ip="127.0.0.1",
|
||||
)
|
||||
|
||||
assert result["entryUrl"].startswith("https://qa.example.com/?sso_code=")
|
||||
assert db.scalar(select(func.count(UserExternalIdentity.id))) == 1
|
||||
|
||||
login = SsoService.exchange(db, code=result["code"], ip="127.0.0.1")
|
||||
assert login["user"].id == user.id
|
||||
assert login["returnUrl"] == "https://student.example.com/home"
|
||||
assert login["token"]
|
||||
|
||||
with pytest.raises(HTTPException) as reused:
|
||||
SsoService.exchange(db, code=result["code"], ip="127.0.0.1")
|
||||
assert reused.value.status_code == 401
|
||||
assert db.scalar(select(func.count(SsoLoginAudit.id))) == 3
|
||||
|
||||
|
||||
def test_sso_rejects_replayed_nonce_and_unlisted_return_url(monkeypatch: pytest.MonkeyPatch):
|
||||
redis = FakeRedis()
|
||||
monkeypatch.setattr("app.services.sso_service.get_sync_redis_client", lambda: redis)
|
||||
with _db() as db:
|
||||
user, client, secret = _seed(db)
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = "nonce-used-only-once-123"
|
||||
raw_body = json.dumps(
|
||||
{"externalUserId": "external-1001", "verifiedPhone": user.phone},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
payload = SsoTicketRequest.model_validate_json(raw_body)
|
||||
signature = _signature(secret, timestamp, nonce, raw_body)
|
||||
SsoService.issue_ticket(
|
||||
db,
|
||||
app_id=client.app_id,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
signature=signature,
|
||||
raw_body=raw_body,
|
||||
payload=payload,
|
||||
ip="127.0.0.1",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as replayed:
|
||||
SsoService.issue_ticket(
|
||||
db,
|
||||
app_id=client.app_id,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
signature=signature,
|
||||
raw_body=raw_body,
|
||||
payload=payload,
|
||||
ip="127.0.0.1",
|
||||
)
|
||||
assert replayed.value.status_code == 409
|
||||
|
||||
another_nonce = "another-unique-nonce-456"
|
||||
invalid_body = json.dumps(
|
||||
{
|
||||
"externalUserId": "external-1001",
|
||||
"verifiedPhone": user.phone,
|
||||
"returnUrl": "https://evil.example.com",
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
with pytest.raises(HTTPException) as invalid_return:
|
||||
SsoService.issue_ticket(
|
||||
db,
|
||||
app_id=client.app_id,
|
||||
timestamp=timestamp,
|
||||
nonce=another_nonce,
|
||||
signature=_signature(secret, timestamp, another_nonce, invalid_body),
|
||||
raw_body=invalid_body,
|
||||
payload=SsoTicketRequest.model_validate_json(invalid_body),
|
||||
ip="127.0.0.1",
|
||||
)
|
||||
assert invalid_return.value.status_code == 400
|
||||
|
||||
|
||||
def test_sso_first_login_requires_verified_existing_phone(monkeypatch: pytest.MonkeyPatch):
|
||||
redis = FakeRedis()
|
||||
monkeypatch.setattr("app.services.sso_service.get_sync_redis_client", lambda: redis)
|
||||
with _db() as db:
|
||||
_user, client, secret = _seed(db)
|
||||
raw_body = b'{"externalUserId":"external-without-phone"}'
|
||||
payload = SsoTicketRequest.model_validate_json(raw_body)
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = "missing-phone-nonce-123"
|
||||
with pytest.raises(HTTPException) as missing_phone:
|
||||
SsoService.issue_ticket(
|
||||
db,
|
||||
app_id=client.app_id,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
signature=_signature(secret, timestamp, nonce, raw_body),
|
||||
raw_body=raw_body,
|
||||
payload=payload,
|
||||
ip="127.0.0.1",
|
||||
)
|
||||
assert missing_phone.value.status_code == 403
|
||||
audit = db.scalar(select(SsoLoginAudit).order_by(SsoLoginAudit.id.desc()))
|
||||
assert audit is not None
|
||||
assert audit.status == "FAILED"
|
||||
@@ -1,6 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>大本营答疑助手</title>
|
||||
|
||||
@@ -9,7 +9,7 @@ import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
|
||||
import PersonalCenterDialog from "./components/PersonalCenterDialog.vue";
|
||||
import SessionDrawer from "./components/SessionDrawer.vue";
|
||||
import SessionQuota from "./components/SessionQuota.vue";
|
||||
import { ApiError, api, clearToken, getToken, streamChat } from "./services/api";
|
||||
import { ApiError, api, clearToken, getToken, saveToken, streamChat } from "./services/api";
|
||||
import type { ChatMessage as ApiMessage, ChatSession, PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
|
||||
|
||||
const user = ref<UserProfile | null>(null);
|
||||
@@ -48,7 +48,32 @@ const activeAbortController = ref<AbortController | null>(null);
|
||||
let toastTimer: number | null = null;
|
||||
let settlementPollVersion = 0;
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(bootstrap);
|
||||
|
||||
async function bootstrap() {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const ssoCode = search.get("sso_code")?.trim() || "";
|
||||
if (ssoCode) {
|
||||
search.delete("sso_code");
|
||||
const query = search.toString();
|
||||
window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`);
|
||||
statusText.value = "正在完成免登录";
|
||||
try {
|
||||
const result = await api.exchangeSso(ssoCode);
|
||||
saveToken(result.token);
|
||||
if (result.returnUrl) window.sessionStorage.setItem("ai-kb-sso-return-url", result.returnUrl);
|
||||
user.value = result.user;
|
||||
await loadSessions();
|
||||
statusText.value = "已连接大本营答疑服务";
|
||||
} catch (error) {
|
||||
clearUserState();
|
||||
showToast(error instanceof Error ? `免登录失败:${error.message}` : "免登录失败,请重新进入或使用手机号登录");
|
||||
} finally {
|
||||
booting.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getToken()) {
|
||||
booting.value = false;
|
||||
statusText.value = "请先登录";
|
||||
@@ -63,7 +88,7 @@ onMounted(async () => {
|
||||
} finally {
|
||||
booting.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
settlementPollVersion += 1;
|
||||
|
||||
@@ -85,6 +85,8 @@ export const api = {
|
||||
}),
|
||||
login: (phone: string, code: string) =>
|
||||
request<LoginResult>("/auth/login", { method: "POST", body: JSON.stringify({ phone, code }) }),
|
||||
exchangeSso: (code: string) =>
|
||||
request<LoginResult>("/auth/sso/exchange", { method: "POST", body: JSON.stringify({ code }) }),
|
||||
logout: () => request<null>("/auth/logout", { method: "POST", body: JSON.stringify({}) }),
|
||||
profile: () => request<UserProfile>("/user/profile"),
|
||||
createSession: () => request<{ sessionId: number }>("/chat/session", { method: "POST", body: JSON.stringify({}) }),
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface LoginResult {
|
||||
token: string;
|
||||
expiredAt: string;
|
||||
user: UserProfile;
|
||||
returnUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface CaptchaResult {
|
||||
|
||||
124
ai_knowledge_base_v2/docs/应用免登录接入说明.md
Normal file
124
ai_knowledge_base_v2/docs/应用免登录接入说明.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# 千问千答应用免登录接入说明
|
||||
|
||||
## 1. 适用场景
|
||||
|
||||
学员已经在其他可信应用完成登录,从该应用进入千问千答时,不再重复输入手机号和验证码。
|
||||
|
||||
接入应用只能在自己的服务端申请一次性授权码。应用密钥不得写入网页、App 安装包、小程序或公开仓库。
|
||||
|
||||
## 2. 准备工作
|
||||
|
||||
管理员在千问千答后台的“应用接入”页面完成:
|
||||
|
||||
1. 填写千问千答用户端公网地址;
|
||||
2. 新增接入应用;
|
||||
3. 保存系统只展示一次的应用密钥;
|
||||
4. 如需学员从千问千答返回来源应用,配置完整的回跳地址白名单。
|
||||
|
||||
首次免登录时,接入应用必须提供学员在本应用内的稳定用户 ID 和已经由本应用验证过的手机号。手机号仅用于首次匹配千问千答学员,后续以账号绑定为准。
|
||||
|
||||
## 3. 申请一次性授权码
|
||||
|
||||
接口:
|
||||
|
||||
```text
|
||||
POST /api/integration/sso/ticket
|
||||
```
|
||||
|
||||
请求体示例:
|
||||
|
||||
```json
|
||||
{"externalUserId":"student-10086","verifiedPhone":"13800138000","displayName":"张同学","returnUrl":"https://student.example.com/home"}
|
||||
```
|
||||
|
||||
请求头:
|
||||
|
||||
```text
|
||||
X-App-Id: 后台生成的应用ID
|
||||
X-Timestamp: 当前 Unix 秒级时间戳
|
||||
X-Nonce: 每次请求生成的唯一随机字符串,长度至少16位
|
||||
X-Signature: HMAC-SHA256 十六进制签名
|
||||
```
|
||||
|
||||
签名原文:
|
||||
|
||||
```text
|
||||
时间戳 + "\n" + 随机字符串 + "\n" + SHA256(原始请求体字节)
|
||||
```
|
||||
|
||||
使用应用密钥对签名原文执行 HMAC-SHA256,输出小写十六进制字符串。必须对实际发送的原始 JSON 字节计算摘要,不要对解析后重新排序的对象签名。
|
||||
|
||||
Python 示例:
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
body = json.dumps(
|
||||
{
|
||||
"externalUserId": "student-10086",
|
||||
"verifiedPhone": "13800138000",
|
||||
"displayName": "张同学",
|
||||
"returnUrl": "https://student.example.com/home",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = secrets.token_urlsafe(24)
|
||||
canonical = f"{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}".encode()
|
||||
signature = hmac.new(APP_SECRET.encode(), canonical, hashlib.sha256).hexdigest()
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"code": "一次性授权码",
|
||||
"expiresInSeconds": 60,
|
||||
"entryUrl": "https://qa.example.com/?sso_code=一次性授权码"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
接入应用收到响应后,让浏览器、App WebView 或小程序 WebView 打开 `entryUrl`。授权码只能使用一次,60 秒后自动失效。
|
||||
|
||||
## 4. 用户匹配规则
|
||||
|
||||
- 已经绑定:按“应用 ID + 外部用户 ID”直接找到千问千答学员;
|
||||
- 首次进入:使用已验证手机号匹配现有学员并建立绑定;
|
||||
- 手机号不在学员名单:拒绝进入,不自动创建无权益账号;
|
||||
- 学员已禁用、尚未生效或权益已过期:拒绝进入;
|
||||
- 同一应用中的一个千问千答学员只能绑定一个外部用户 ID。
|
||||
|
||||
绑定错误时,管理员可以在“应用接入 → 账号绑定”中解除绑定,学员下次进入时重新核对手机号。
|
||||
|
||||
## 5. 安全规则
|
||||
|
||||
- 所有生产接口必须使用 HTTPS;
|
||||
- 应用密钥只保存在接入应用服务端;
|
||||
- 请求时间戳允许误差不超过 5 分钟;
|
||||
- `X-Nonce` 在 5 分钟内不得重复;
|
||||
- `returnUrl` 必须与后台白名单完全匹配;
|
||||
- Redis 不可用时停止签发和兑换授权码,不降级为长期 Token;
|
||||
- 更新应用密钥后,旧密钥立即失效;
|
||||
- 停用接入应用后,该应用不能继续申请或兑换登录态;
|
||||
- 登录成功和失败都会写入登录审计。
|
||||
|
||||
## 6. 验收清单
|
||||
|
||||
- 已登录来源应用的学员点击后直接进入千问千答;
|
||||
- 首次进入能按已验证手机号正确绑定;
|
||||
- 后续进入不再依赖手机号;
|
||||
- 同一个授权码第二次兑换失败;
|
||||
- 错误签名、过期时间戳、重复随机数均被拒绝;
|
||||
- 非白名单回跳地址被拒绝;
|
||||
- 禁用或过期学员不能进入;
|
||||
- 停用应用和更新密钥立即生效;
|
||||
- 手机号验证码登录仍可正常使用。
|
||||
Reference in New Issue
Block a user