feat: 增加可信应用免登录接入
This commit is contained in:
305
ai_knowledge_base_v2/apps/backend/app/api/admin_sso.py
Normal file
305
ai_knowledge_base_v2/apps/backend/app/api/admin_sso.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.pagination import page_result
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.sso import SsoClientSaveRequest, SsoClientUpdateRequest, SsoPublicConfigRequest
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.secret_service import SecretService
|
||||
from app.services.sso_service import _json_list
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/sso/config")
|
||||
def get_sso_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
value = db.scalar(
|
||||
select(SystemConfig.config_value).where(SystemConfig.config_key == "sso_user_client_url")
|
||||
)
|
||||
return api_success({"userClientUrl": value or ""})
|
||||
|
||||
|
||||
@router.put("/sso/config")
|
||||
def save_sso_config(
|
||||
payload: SsoPublicConfigRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
config = db.scalar(
|
||||
select(SystemConfig).where(SystemConfig.config_key == "sso_user_client_url")
|
||||
)
|
||||
if config is None:
|
||||
config = SystemConfig(
|
||||
config_key="sso_user_client_url",
|
||||
config_value=payload.userClientUrl,
|
||||
description="其他应用完成免登录后进入的千问千答用户端公网地址。",
|
||||
updated_by=current_admin.id,
|
||||
)
|
||||
db.add(config)
|
||||
else:
|
||||
config.config_value = payload.userClientUrl
|
||||
config.updated_by = current_admin.id
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="sso",
|
||||
action="save_public_url",
|
||||
target_id=config.id,
|
||||
)
|
||||
db.commit()
|
||||
return api_success({"userClientUrl": payload.userClientUrl})
|
||||
|
||||
|
||||
@router.get("/sso/client/list")
|
||||
def list_sso_clients(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
identity_counts = (
|
||||
select(
|
||||
UserExternalIdentity.client_id.label("client_id"),
|
||||
func.count(UserExternalIdentity.id).label("identity_count"),
|
||||
)
|
||||
.group_by(UserExternalIdentity.client_id)
|
||||
.subquery()
|
||||
)
|
||||
rows = db.execute(
|
||||
select(SsoClient, func.coalesce(identity_counts.c.identity_count, 0))
|
||||
.outerjoin(identity_counts, identity_counts.c.client_id == SsoClient.id)
|
||||
.order_by(SsoClient.created_at.desc(), SsoClient.id.desc())
|
||||
).all()
|
||||
return api_success([_client_item(client, int(identity_count)) for client, identity_count in rows])
|
||||
|
||||
|
||||
@router.post("/sso/client")
|
||||
def create_sso_client(
|
||||
payload: SsoClientSaveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
app_id = payload.appId.strip()
|
||||
if db.scalar(select(SsoClient.id).where(SsoClient.app_id == app_id)) is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="应用ID已存在")
|
||||
plaintext_secret = secrets.token_urlsafe(32)
|
||||
client = SsoClient(
|
||||
app_id=app_id,
|
||||
name=payload.name.strip(),
|
||||
client_secret=SecretService.encrypt(plaintext_secret),
|
||||
redirect_uris=json.dumps(payload.redirectUris, ensure_ascii=False),
|
||||
status=payload.status,
|
||||
created_by=current_admin.id,
|
||||
)
|
||||
db.add(client)
|
||||
db.flush()
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="sso",
|
||||
action="create_client",
|
||||
target_id=client.id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(client)
|
||||
return api_success({**_client_item(client, 0), "clientSecret": plaintext_secret})
|
||||
|
||||
|
||||
@router.put("/sso/client/{client_id}")
|
||||
def update_sso_client(
|
||||
client_id: int,
|
||||
payload: SsoClientUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
client = _require_client(db, client_id)
|
||||
client.name = payload.name.strip()
|
||||
client.redirect_uris = json.dumps(payload.redirectUris, ensure_ascii=False)
|
||||
client.status = payload.status
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="sso",
|
||||
action="update_client",
|
||||
target_id=client.id,
|
||||
)
|
||||
db.commit()
|
||||
identity_count = db.scalar(
|
||||
select(func.count(UserExternalIdentity.id)).where(UserExternalIdentity.client_id == client.id)
|
||||
) or 0
|
||||
return api_success(_client_item(client, identity_count))
|
||||
|
||||
|
||||
@router.post("/sso/client/{client_id}/secret/rotate")
|
||||
def rotate_sso_client_secret(
|
||||
client_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
client = _require_client(db, client_id)
|
||||
plaintext_secret = secrets.token_urlsafe(32)
|
||||
client.client_secret = SecretService.encrypt(plaintext_secret)
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="sso",
|
||||
action="rotate_secret",
|
||||
target_id=client.id,
|
||||
)
|
||||
db.commit()
|
||||
return api_success({"clientId": client.id, "clientSecret": plaintext_secret})
|
||||
|
||||
|
||||
@router.get("/sso/identity/list")
|
||||
def list_sso_identities(
|
||||
clientId: int | None = Query(default=None, gt=0),
|
||||
keyword: str = Query(default="", max_length=100),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=10, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
filters = []
|
||||
if clientId:
|
||||
filters.append(UserExternalIdentity.client_id == clientId)
|
||||
if keyword.strip():
|
||||
value = f"%{keyword.strip()}%"
|
||||
filters.append(
|
||||
or_(
|
||||
UserExternalIdentity.external_user_id.ilike(value),
|
||||
User.phone.ilike(value),
|
||||
User.name.ilike(value),
|
||||
SsoClient.name.ilike(value),
|
||||
)
|
||||
)
|
||||
total = db.scalar(
|
||||
select(func.count(UserExternalIdentity.id))
|
||||
.join(SsoClient, SsoClient.id == UserExternalIdentity.client_id)
|
||||
.join(User, User.id == UserExternalIdentity.user_id)
|
||||
.where(*filters)
|
||||
) or 0
|
||||
rows = db.execute(
|
||||
select(UserExternalIdentity, SsoClient, User)
|
||||
.join(SsoClient, SsoClient.id == UserExternalIdentity.client_id)
|
||||
.join(User, User.id == UserExternalIdentity.user_id)
|
||||
.where(*filters)
|
||||
.order_by(UserExternalIdentity.last_login_at.desc(), UserExternalIdentity.id.desc())
|
||||
.offset((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
).all()
|
||||
items = [_identity_item(identity, client, user) for identity, client, user in rows]
|
||||
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
@router.delete("/sso/identity/{identity_id}")
|
||||
def delete_sso_identity(
|
||||
identity_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
identity = db.get(UserExternalIdentity, identity_id)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号绑定不存在")
|
||||
db.execute(delete(UserExternalIdentity).where(UserExternalIdentity.id == identity_id))
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="sso",
|
||||
action="unlink_identity",
|
||||
target_id=identity_id,
|
||||
)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
@router.get("/sso/audit/list")
|
||||
def list_sso_audits(
|
||||
clientId: int | None = Query(default=None, gt=0),
|
||||
auditStatus: str = Query(default="", max_length=20),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=10, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
filters = []
|
||||
if clientId:
|
||||
filters.append(SsoLoginAudit.client_id == clientId)
|
||||
if auditStatus:
|
||||
filters.append(SsoLoginAudit.status == auditStatus.upper())
|
||||
total = db.scalar(select(func.count(SsoLoginAudit.id)).where(*filters)) or 0
|
||||
rows = db.execute(
|
||||
select(SsoLoginAudit, SsoClient)
|
||||
.outerjoin(SsoClient, SsoClient.id == SsoLoginAudit.client_id)
|
||||
.where(*filters)
|
||||
.order_by(SsoLoginAudit.created_at.desc(), SsoLoginAudit.id.desc())
|
||||
.offset((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
).all()
|
||||
items = [_audit_item(audit, client) for audit, client in rows]
|
||||
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
def _require_client(db: Session, client_id: int) -> SsoClient:
|
||||
client = db.get(SsoClient, client_id)
|
||||
if client is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="接入应用不存在")
|
||||
return client
|
||||
|
||||
|
||||
def _client_item(client: SsoClient, identity_count: int) -> dict:
|
||||
return {
|
||||
"id": client.id,
|
||||
"appId": client.app_id,
|
||||
"name": client.name,
|
||||
"redirectUris": _json_list(client.redirect_uris),
|
||||
"status": client.status,
|
||||
"identityCount": identity_count,
|
||||
"lastUsedAt": client.last_used_at,
|
||||
"createdAt": client.created_at,
|
||||
"updatedAt": client.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _identity_item(identity: UserExternalIdentity, client: SsoClient, user: User) -> dict:
|
||||
return {
|
||||
"id": identity.id,
|
||||
"clientId": client.id,
|
||||
"clientName": client.name,
|
||||
"appId": client.app_id,
|
||||
"userId": user.id,
|
||||
"userName": user.name,
|
||||
"phone": user.phone,
|
||||
"externalUserId": identity.external_user_id,
|
||||
"displayNameSnapshot": identity.display_name_snapshot,
|
||||
"lastLoginAt": identity.last_login_at,
|
||||
"createdAt": identity.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _audit_item(audit: SsoLoginAudit, client: SsoClient | None) -> dict:
|
||||
return {
|
||||
"id": audit.id,
|
||||
"appId": client.app_id if client else None,
|
||||
"clientName": client.name if client else None,
|
||||
"userId": audit.user_id,
|
||||
"externalUserId": audit.external_user_id,
|
||||
"action": audit.action,
|
||||
"status": audit.status,
|
||||
"errorMessage": audit.error_message,
|
||||
"ip": audit.ip,
|
||||
"createdAt": audit.created_at,
|
||||
}
|
||||
@@ -7,9 +7,11 @@ from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_token_payload
|
||||
from app.core.responses import api_success
|
||||
from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendSmsRequest
|
||||
from app.schemas.sso import SsoExchangeRequest
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.captcha_service import CaptchaService
|
||||
from app.services.security_state_service import client_ip
|
||||
from app.services.sso_service import SsoService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -31,6 +33,12 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
|
||||
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/sso/exchange")
|
||||
def exchange_sso(payload: SsoExchangeRequest, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
result = SsoService.exchange(db, code=payload.code, ip=client_ip(request))
|
||||
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
|
||||
AuthService.logout(token_payload)
|
||||
|
||||
43
ai_knowledge_base_v2/apps/backend/app/api/integration_sso.py
Normal file
43
ai_knowledge_base_v2/apps/backend/app/api/integration_sso.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.responses import api_success
|
||||
from app.schemas.sso import SsoTicketRequest
|
||||
from app.services.security_state_service import client_ip
|
||||
from app.services.sso_service import SsoService
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/ticket")
|
||||
async def create_ticket(request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
raw_body = await request.body()
|
||||
try:
|
||||
payload = SsoTicketRequest.model_validate_json(raw_body)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="接入用户参数不完整") from exc
|
||||
|
||||
app_id = request.headers.get("X-App-Id", "").strip()
|
||||
timestamp = request.headers.get("X-Timestamp", "").strip()
|
||||
nonce = request.headers.get("X-Nonce", "").strip()
|
||||
signature = request.headers.get("X-Signature", "").strip()
|
||||
if not all((app_id, timestamp, nonce, signature)):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少应用签名信息")
|
||||
|
||||
return api_success(
|
||||
SsoService.issue_ticket(
|
||||
db,
|
||||
app_id=app_id,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
signature=signature,
|
||||
raw_body=raw_body,
|
||||
payload=payload,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
)
|
||||
@@ -12,16 +12,19 @@ from app.api import (
|
||||
admin_knowledge_lifecycle,
|
||||
admin_records,
|
||||
admin_settings,
|
||||
admin_sso,
|
||||
admin_users,
|
||||
auth,
|
||||
chat,
|
||||
health,
|
||||
integration_sso,
|
||||
user,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(integration_sso.router, prefix="/integration/sso", tags=["integration-sso"])
|
||||
api_router.include_router(user.router, prefix="/user", tags=["user"])
|
||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
|
||||
@@ -33,4 +36,5 @@ api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])
|
||||
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"])
|
||||
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])
|
||||
|
||||
Reference in New Issue
Block a user