feat: 增加可信应用免登录接入

This commit is contained in:
2026-08-03 18:28:40 +08:00
parent 59c306f88f
commit 26109648af
21 changed files with 1844 additions and 6 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)}`),

View File

@@ -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;

View File

@@ -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")

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

View File

@@ -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)

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

View File

@@ -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"])

View File

@@ -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",

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

View File

@@ -22,6 +22,7 @@ class LoginResponse(BaseModel):
token: str
expiredAt: datetime
user: UserProfile
returnUrl: str | None = None
class CaptchaResponse(BaseModel):

View 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

View File

@@ -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="账号已禁用")

View 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

View 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"

View File

@@ -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>

View File

@@ -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;

View File

@@ -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({}) }),

View File

@@ -119,6 +119,7 @@ export interface LoginResult {
token: string;
expiredAt: string;
user: UserProfile;
returnUrl?: string | null;
}
export interface CaptchaResult {

View 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. 验收清单
- 已登录来源应用的学员点击后直接进入千问千答;
- 首次进入能按已验证手机号正确绑定;
- 后续进入不再依赖手机号;
- 同一个授权码第二次兑换失败;
- 错误签名、过期时间戳、重复随机数均被拒绝;
- 非白名单回跳地址被拒绝;
- 禁用或过期学员不能进入;
- 停用应用和更新密钥立即生效;
- 手机号验证码登录仍可正常使用。