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 {