feat: 增加可信应用免登录接入
This commit is contained in:
@@ -44,6 +44,9 @@ const RetrievalLogView = defineAsyncComponent(
|
|||||||
const UserManagementView = defineAsyncComponent(
|
const UserManagementView = defineAsyncComponent(
|
||||||
() => import("./components/UserManagementView.vue"),
|
() => import("./components/UserManagementView.vue"),
|
||||||
);
|
);
|
||||||
|
const SsoIntegrationView = defineAsyncComponent(
|
||||||
|
() => import("./components/SsoIntegrationView.vue"),
|
||||||
|
);
|
||||||
|
|
||||||
const admin = ref<AdminProfile | null>(null);
|
const admin = ref<AdminProfile | null>(null);
|
||||||
const activeMenu = ref("dashboard");
|
const activeMenu = ref("dashboard");
|
||||||
@@ -608,6 +611,12 @@ async function clearFeishuCache() {
|
|||||||
>
|
>
|
||||||
系统配置
|
系统配置
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: activeMenu === 'sso' }"
|
||||||
|
@click="switchMenu('sso')"
|
||||||
|
>
|
||||||
|
应用接入
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="{ active: activeMenu === 'records' }"
|
:class="{ active: activeMenu === 'records' }"
|
||||||
@click="switchMenu('records')"
|
@click="switchMenu('records')"
|
||||||
@@ -1003,6 +1012,8 @@ async function clearFeishuCache() {
|
|||||||
v-if="activeMenu === 'content-generation'"
|
v-if="activeMenu === 'content-generation'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SsoIntegrationView v-if="activeMenu === 'sso'" />
|
||||||
|
|
||||||
<template v-if="activeMenu === 'configs'">
|
<template v-if="activeMenu === 'configs'">
|
||||||
<div class="page-head inline">
|
<div class="page-head inline">
|
||||||
<div>
|
<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,
|
PromptHistoryItem,
|
||||||
QuestionInsightRefreshResult,
|
QuestionInsightRefreshResult,
|
||||||
QuestionInsightSummary,
|
QuestionInsightSummary,
|
||||||
|
SsoAuditItem,
|
||||||
|
SsoClientItem,
|
||||||
|
SsoIdentityItem,
|
||||||
TopicSessionRecord,
|
TopicSessionRecord,
|
||||||
TopicSummaryRecord,
|
TopicSummaryRecord,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
@@ -263,6 +266,27 @@ export const api = {
|
|||||||
configs: () => request<SystemConfigItem[]>("/admin/config"),
|
configs: () => request<SystemConfigItem[]>("/admin/config"),
|
||||||
saveConfig: (payload: Record<string, unknown>) =>
|
saveConfig: (payload: Record<string, unknown>) =>
|
||||||
request<SystemConfigItem>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
|
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)}`),
|
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
|
||||||
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number } = {}) =>
|
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number } = {}) =>
|
||||||
request<ChatDetail>(`/admin/chat/${sessionId}${queryString(query)}`),
|
request<ChatDetail>(`/admin/chat/${sessionId}${queryString(query)}`),
|
||||||
|
|||||||
@@ -18,6 +18,46 @@ export interface AdminProfile {
|
|||||||
status: number;
|
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 {
|
export interface DashboardStats {
|
||||||
userCount: number;
|
userCount: number;
|
||||||
sessionCount: 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.dependencies import get_current_token_payload
|
||||||
from app.core.responses import api_success
|
from app.core.responses import api_success
|
||||||
from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendSmsRequest
|
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.auth_service import AuthService
|
||||||
from app.services.captcha_service import CaptchaService
|
from app.services.captcha_service import CaptchaService
|
||||||
from app.services.security_state_service import client_ip
|
from app.services.security_state_service import client_ip
|
||||||
|
from app.services.sso_service import SsoService
|
||||||
|
|
||||||
router = APIRouter()
|
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"))
|
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")
|
@router.post("/logout")
|
||||||
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
|
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
|
||||||
AuthService.logout(token_payload)
|
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_knowledge_lifecycle,
|
||||||
admin_records,
|
admin_records,
|
||||||
admin_settings,
|
admin_settings,
|
||||||
|
admin_sso,
|
||||||
admin_users,
|
admin_users,
|
||||||
auth,
|
auth,
|
||||||
chat,
|
chat,
|
||||||
health,
|
health,
|
||||||
|
integration_sso,
|
||||||
user,
|
user,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
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(user.router, prefix="/user", tags=["user"])
|
||||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||||
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
|
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.router, prefix="/admin", tags=["admin"])
|
||||||
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])
|
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_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"])
|
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.models.knowledge import (
|
|||||||
UserKnowledgePermission,
|
UserKnowledgePermission,
|
||||||
)
|
)
|
||||||
from app.models.logs import AiRequestLog, LogRetentionPolicy, OperationLog, StorageSnapshot
|
from app.models.logs import AiRequestLog, LogRetentionPolicy, OperationLog, StorageSnapshot
|
||||||
|
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -53,6 +54,8 @@ __all__ = [
|
|||||||
"PeriodicReport",
|
"PeriodicReport",
|
||||||
"LogRetentionPolicy",
|
"LogRetentionPolicy",
|
||||||
"StorageSnapshot",
|
"StorageSnapshot",
|
||||||
|
"SsoClient",
|
||||||
|
"SsoLoginAudit",
|
||||||
"TopicSession",
|
"TopicSession",
|
||||||
"TopicSummary",
|
"TopicSummary",
|
||||||
"Prompt",
|
"Prompt",
|
||||||
@@ -62,6 +65,7 @@ __all__ = [
|
|||||||
"ShareDraft",
|
"ShareDraft",
|
||||||
"TeacherHelpCard",
|
"TeacherHelpCard",
|
||||||
"User",
|
"User",
|
||||||
|
"UserExternalIdentity",
|
||||||
"UserEntitlement",
|
"UserEntitlement",
|
||||||
"UserEntitlementLog",
|
"UserEntitlementLog",
|
||||||
"UserGrowthProfile",
|
"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
|
token: str
|
||||||
expiredAt: datetime
|
expiredAt: datetime
|
||||||
user: UserProfile
|
user: UserProfile
|
||||||
|
returnUrl: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CaptchaResponse(BaseModel):
|
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,
|
ip: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
user = cls._get_existing_user(db, phone)
|
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)
|
captcha_key = cls._captcha_required_key(phone)
|
||||||
has_captcha = bool(captcha_id or captcha_code)
|
has_captcha = bool(captcha_id or captcha_code)
|
||||||
if has_captcha:
|
if has_captcha:
|
||||||
@@ -65,7 +65,7 @@ class AuthService:
|
|||||||
raise
|
raise
|
||||||
SecurityStateService.clear(failure_key)
|
SecurityStateService.clear(failure_key)
|
||||||
user = cls._get_existing_user(db, phone)
|
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)
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
user.last_login_at = now
|
user.last_login_at = now
|
||||||
@@ -96,7 +96,7 @@ class AuthService:
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中,请联系管理员")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中,请联系管理员")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ensure_user_can_login(user: User) -> None:
|
def ensure_user_can_login(user: User) -> None:
|
||||||
now = datetime.now(UTC).replace(tzinfo=None)
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
if user.status != 1:
|
if user.status != 1:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
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>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>大本营答疑助手</title>
|
<title>大本营答疑助手</title>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
|
|||||||
import PersonalCenterDialog from "./components/PersonalCenterDialog.vue";
|
import PersonalCenterDialog from "./components/PersonalCenterDialog.vue";
|
||||||
import SessionDrawer from "./components/SessionDrawer.vue";
|
import SessionDrawer from "./components/SessionDrawer.vue";
|
||||||
import SessionQuota from "./components/SessionQuota.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";
|
import type { ChatMessage as ApiMessage, ChatSession, PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
|
||||||
|
|
||||||
const user = ref<UserProfile | null>(null);
|
const user = ref<UserProfile | null>(null);
|
||||||
@@ -48,7 +48,32 @@ const activeAbortController = ref<AbortController | null>(null);
|
|||||||
let toastTimer: number | null = null;
|
let toastTimer: number | null = null;
|
||||||
let settlementPollVersion = 0;
|
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()) {
|
if (!getToken()) {
|
||||||
booting.value = false;
|
booting.value = false;
|
||||||
statusText.value = "请先登录";
|
statusText.value = "请先登录";
|
||||||
@@ -63,7 +88,7 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
booting.value = false;
|
booting.value = false;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
settlementPollVersion += 1;
|
settlementPollVersion += 1;
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
login: (phone: string, code: string) =>
|
login: (phone: string, code: string) =>
|
||||||
request<LoginResult>("/auth/login", { method: "POST", body: JSON.stringify({ phone, code }) }),
|
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({}) }),
|
logout: () => request<null>("/auth/logout", { method: "POST", body: JSON.stringify({}) }),
|
||||||
profile: () => request<UserProfile>("/user/profile"),
|
profile: () => request<UserProfile>("/user/profile"),
|
||||||
createSession: () => request<{ sessionId: number }>("/chat/session", { method: "POST", body: JSON.stringify({}) }),
|
createSession: () => request<{ sessionId: number }>("/chat/session", { method: "POST", body: JSON.stringify({}) }),
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export interface LoginResult {
|
|||||||
token: string;
|
token: string;
|
||||||
expiredAt: string;
|
expiredAt: string;
|
||||||
user: UserProfile;
|
user: UserProfile;
|
||||||
|
returnUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaptchaResult {
|
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