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

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