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"])
|
||||
|
||||
@@ -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",
|
||||
|
||||
75
ai_knowledge_base_v2/apps/backend/app/models/sso.py
Normal file
75
ai_knowledge_base_v2/apps/backend/app/models/sso.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class SsoClient(Base):
|
||||
__tablename__ = "sys_sso_client"
|
||||
__table_args__ = (Index("ix_sys_sso_client_status", "status"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
app_id: Mapped[str] = mapped_column(String(80), unique=True, index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
client_secret: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
redirect_uris: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
|
||||
status: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class UserExternalIdentity(Base):
|
||||
__tablename__ = "sys_user_external_identity"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_id", "external_user_id", name="uq_sso_identity_client_external"),
|
||||
UniqueConstraint("client_id", "user_id", name="uq_sso_identity_client_user"),
|
||||
Index("ix_sso_identity_user", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("sys_sso_client.id"), index=True, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), nullable=False)
|
||||
external_user_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
phone_snapshot: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
display_name_snapshot: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class SsoLoginAudit(Base):
|
||||
__tablename__ = "sys_sso_login_audit"
|
||||
__table_args__ = (
|
||||
Index("ix_sso_audit_client_created", "client_id", "created_at"),
|
||||
Index("ix_sso_audit_user_created", "user_id", "created_at"),
|
||||
Index("ix_sso_audit_status_created", "status", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
client_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
external_user_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
@@ -22,6 +22,7 @@ class LoginResponse(BaseModel):
|
||||
token: str
|
||||
expiredAt: datetime
|
||||
user: UserProfile
|
||||
returnUrl: str | None = None
|
||||
|
||||
|
||||
class CaptchaResponse(BaseModel):
|
||||
|
||||
99
ai_knowledge_base_v2/apps/backend/app/schemas/sso.py
Normal file
99
ai_knowledge_base_v2/apps/backend/app/schemas/sso.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class SsoTicketRequest(BaseModel):
|
||||
externalUserId: str = Field(min_length=1, max_length=120)
|
||||
verifiedPhone: str | None = Field(default=None, min_length=11, max_length=20)
|
||||
displayName: str | None = Field(default=None, max_length=100)
|
||||
returnUrl: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class SsoExchangeRequest(BaseModel):
|
||||
code: str = Field(min_length=20, max_length=200)
|
||||
|
||||
|
||||
class SsoClientSaveRequest(BaseModel):
|
||||
appId: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]+$")
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
redirectUris: list[str] = Field(default_factory=list, max_length=20)
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
|
||||
@field_validator("redirectUris")
|
||||
@classmethod
|
||||
def validate_redirect_uris(cls, values: list[str]) -> list[str]:
|
||||
cleaned: list[str] = []
|
||||
for raw in values:
|
||||
value = raw.strip()
|
||||
if not value:
|
||||
continue
|
||||
if not value.startswith(("https://", "http://localhost", "http://127.0.0.1")):
|
||||
raise ValueError("回跳地址必须使用 HTTPS;本地调试可使用 localhost 或 127.0.0.1")
|
||||
if value not in cleaned:
|
||||
cleaned.append(value)
|
||||
return cleaned
|
||||
|
||||
|
||||
class SsoClientUpdateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
redirectUris: list[str] = Field(default_factory=list, max_length=20)
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
|
||||
@field_validator("redirectUris")
|
||||
@classmethod
|
||||
def validate_redirect_uris(cls, values: list[str]) -> list[str]:
|
||||
return SsoClientSaveRequest.validate_redirect_uris(values)
|
||||
|
||||
|
||||
class SsoPublicConfigRequest(BaseModel):
|
||||
userClientUrl: str = Field(default="", max_length=1000)
|
||||
|
||||
@field_validator("userClientUrl")
|
||||
@classmethod
|
||||
def validate_user_client_url(cls, value: str) -> str:
|
||||
cleaned = value.strip().rstrip("/")
|
||||
if cleaned and not cleaned.startswith(("https://", "http://localhost", "http://127.0.0.1")):
|
||||
raise ValueError("用户端公网地址必须使用 HTTPS;本地调试可使用 localhost 或 127.0.0.1")
|
||||
return cleaned
|
||||
|
||||
|
||||
class SsoClientItem(BaseModel):
|
||||
id: int
|
||||
appId: str
|
||||
name: str
|
||||
redirectUris: list[str]
|
||||
status: int
|
||||
identityCount: int
|
||||
lastUsedAt: datetime | None = None
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
|
||||
|
||||
class SsoIdentityItem(BaseModel):
|
||||
id: int
|
||||
clientId: int
|
||||
clientName: str
|
||||
appId: str
|
||||
userId: int
|
||||
userName: str
|
||||
phone: str
|
||||
externalUserId: str
|
||||
displayNameSnapshot: str | None = None
|
||||
lastLoginAt: datetime | None = None
|
||||
createdAt: datetime
|
||||
|
||||
|
||||
class SsoAuditItem(BaseModel):
|
||||
id: int
|
||||
appId: str | None = None
|
||||
clientName: str | None = None
|
||||
userId: int | None = None
|
||||
externalUserId: str | None = None
|
||||
action: str
|
||||
status: str
|
||||
errorMessage: str | None = None
|
||||
ip: str | None = None
|
||||
createdAt: datetime
|
||||
@@ -26,7 +26,7 @@ class AuthService:
|
||||
ip: str,
|
||||
) -> 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="账号已禁用")
|
||||
|
||||
387
ai_knowledge_base_v2/apps/backend/app/services/sso_service.py
Normal file
387
ai_knowledge_base_v2/apps/backend/app/services/sso_service.py
Normal file
@@ -0,0 +1,387 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import create_access_token
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.sso import SsoTicketRequest
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
from app.services.security_state_service import SecurityStateService
|
||||
from app.services.secret_service import SecretService
|
||||
|
||||
|
||||
class SsoService:
|
||||
CODE_TTL_SECONDS = 60
|
||||
SIGNATURE_WINDOW_SECONDS = 300
|
||||
CODE_NAMESPACE = "auth:sso:code"
|
||||
NONCE_NAMESPACE = "auth:sso:nonce"
|
||||
|
||||
@classmethod
|
||||
def issue_ticket(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
app_id: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
signature: str,
|
||||
raw_body: bytes,
|
||||
payload: SsoTicketRequest,
|
||||
ip: str,
|
||||
) -> dict:
|
||||
client = db.scalar(
|
||||
select(SsoClient).where(SsoClient.app_id == app_id).with_for_update()
|
||||
)
|
||||
if client is None or client.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="接入应用不存在或已停用")
|
||||
|
||||
try:
|
||||
cls._verify_request(client, timestamp, nonce, signature, raw_body)
|
||||
cls._reserve_nonce(client, nonce)
|
||||
SecurityStateService.enforce_limit(
|
||||
f"rate:sso:ticket:{client.id}",
|
||||
limit=600,
|
||||
window_seconds=60,
|
||||
message="该应用免登录请求过于频繁,请稍后再试",
|
||||
)
|
||||
identity, user = cls._resolve_identity(db, client, payload)
|
||||
cls._validate_return_url(client, payload.returnUrl)
|
||||
AuthService.ensure_user_can_login(user)
|
||||
|
||||
now = _db_now(db)
|
||||
client.last_used_at = now
|
||||
identity.phone_snapshot = payload.verifiedPhone or identity.phone_snapshot
|
||||
identity.display_name_snapshot = payload.displayName or identity.display_name_snapshot
|
||||
db.add(
|
||||
SsoLoginAudit(
|
||||
client_id=client.id,
|
||||
user_id=user.id,
|
||||
external_user_id=payload.externalUserId,
|
||||
action="ticket",
|
||||
status="SUCCESS",
|
||||
ip=ip,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
code = cls._store_ticket(
|
||||
{
|
||||
"clientId": client.id,
|
||||
"userId": user.id,
|
||||
"identityId": identity.id,
|
||||
"externalUserId": payload.externalUserId,
|
||||
"returnUrl": payload.returnUrl,
|
||||
"issuedAt": int(time.time()),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"code": code,
|
||||
"expiresInSeconds": cls.CODE_TTL_SECONDS,
|
||||
"entryUrl": cls._entry_url(db, code),
|
||||
}
|
||||
except HTTPException as exc:
|
||||
cls.record_failure(
|
||||
db,
|
||||
client_id=client.id,
|
||||
external_user_id=payload.externalUserId,
|
||||
action="ticket",
|
||||
error_message=str(exc.detail),
|
||||
ip=ip,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
cls.record_failure(
|
||||
db,
|
||||
client_id=client.id,
|
||||
external_user_id=payload.externalUserId,
|
||||
action="ticket",
|
||||
error_message="单点登录服务暂时不可用",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="单点登录服务暂时不可用",
|
||||
) from exc
|
||||
|
||||
@classmethod
|
||||
def exchange(cls, db: Session, *, code: str, ip: str) -> dict:
|
||||
try:
|
||||
ticket = cls._consume_ticket(code)
|
||||
except HTTPException as exc:
|
||||
cls.record_failure(
|
||||
db,
|
||||
client_id=None,
|
||||
action="exchange",
|
||||
error_message=str(exc.detail),
|
||||
ip=ip,
|
||||
)
|
||||
raise
|
||||
client_id = _int_value(ticket.get("clientId"))
|
||||
user_id = _int_value(ticket.get("userId"))
|
||||
identity_id = _int_value(ticket.get("identityId"))
|
||||
external_user_id = str(ticket.get("externalUserId") or "")
|
||||
|
||||
try:
|
||||
client = db.get(SsoClient, client_id)
|
||||
identity = db.get(UserExternalIdentity, identity_id)
|
||||
user = db.get(User, user_id)
|
||||
if client is None or client.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="接入应用已停用")
|
||||
if (
|
||||
identity is None
|
||||
or identity.client_id != client.id
|
||||
or identity.user_id != user_id
|
||||
or identity.external_user_id != external_user_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="外部账号绑定已失效")
|
||||
if user is None or user.is_deleted:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||
AuthService.ensure_user_can_login(user)
|
||||
|
||||
now = _db_now(db)
|
||||
identity.last_login_at = now
|
||||
user.last_login_at = now
|
||||
db.add(
|
||||
SsoLoginAudit(
|
||||
client_id=client.id,
|
||||
user_id=user.id,
|
||||
external_user_id=external_user_id,
|
||||
action="exchange",
|
||||
status="SUCCESS",
|
||||
ip=ip,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
token, expired_at = create_access_token(str(user.id), "user")
|
||||
return {
|
||||
"token": token,
|
||||
"expiredAt": expired_at,
|
||||
"user": user,
|
||||
"returnUrl": ticket.get("returnUrl"),
|
||||
}
|
||||
except HTTPException as exc:
|
||||
cls.record_failure(
|
||||
db,
|
||||
client_id=client_id or None,
|
||||
user_id=user_id or None,
|
||||
external_user_id=external_user_id,
|
||||
action="exchange",
|
||||
error_message=str(exc.detail),
|
||||
ip=ip,
|
||||
)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def record_failure(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
client_id: int | None,
|
||||
action: str,
|
||||
error_message: str,
|
||||
ip: str,
|
||||
user_id: int | None = None,
|
||||
external_user_id: str | None = None,
|
||||
) -> None:
|
||||
db.rollback()
|
||||
db.add(
|
||||
SsoLoginAudit(
|
||||
client_id=client_id,
|
||||
user_id=user_id,
|
||||
external_user_id=external_user_id,
|
||||
action=action,
|
||||
status="FAILED",
|
||||
error_message=error_message[:500],
|
||||
ip=ip,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@classmethod
|
||||
def _verify_request(
|
||||
cls,
|
||||
client: SsoClient,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
signature: str,
|
||||
raw_body: bytes,
|
||||
) -> None:
|
||||
try:
|
||||
request_time = int(timestamp)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用时间戳无效") from exc
|
||||
if abs(int(time.time()) - request_time) > cls.SIGNATURE_WINDOW_SECONDS:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用请求已过期")
|
||||
if not 16 <= len(nonce) <= 120:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用随机数无效")
|
||||
|
||||
body_hash = hashlib.sha256(raw_body).hexdigest()
|
||||
canonical = f"{timestamp}\n{nonce}\n{body_hash}".encode("utf-8")
|
||||
secret = SecretService.decrypt(client.client_secret).encode("utf-8")
|
||||
expected = hmac.new(secret, canonical, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, signature.lower()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用签名无效")
|
||||
|
||||
@classmethod
|
||||
def _reserve_nonce(cls, client: SsoClient, nonce: str) -> None:
|
||||
redis = cls._required_redis()
|
||||
key = f"{cls.NONCE_NAMESPACE}:{client.id}:{hashlib.sha256(nonce.encode()).hexdigest()}"
|
||||
try:
|
||||
reserved = redis.set(key, "1", ex=cls.SIGNATURE_WINDOW_SECONDS, nx=True)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="单点登录服务暂时不可用",
|
||||
) from exc
|
||||
if not reserved:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="应用请求已被使用")
|
||||
|
||||
@classmethod
|
||||
def _resolve_identity(
|
||||
cls,
|
||||
db: Session,
|
||||
client: SsoClient,
|
||||
payload: SsoTicketRequest,
|
||||
) -> tuple[UserExternalIdentity, User]:
|
||||
identity = db.scalar(
|
||||
select(UserExternalIdentity).where(
|
||||
UserExternalIdentity.client_id == client.id,
|
||||
UserExternalIdentity.external_user_id == payload.externalUserId,
|
||||
)
|
||||
)
|
||||
if identity is not None:
|
||||
user = db.get(User, identity.user_id)
|
||||
if user is None or user.is_deleted:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="绑定的学员账号不存在")
|
||||
return identity, user
|
||||
|
||||
if not payload.verifiedPhone:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="首次登录必须提供已验证手机号")
|
||||
user = db.scalar(
|
||||
select(User).where(User.phone == payload.verifiedPhone, User.is_deleted == 0)
|
||||
)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中")
|
||||
existing_user_binding = db.scalar(
|
||||
select(UserExternalIdentity).where(
|
||||
UserExternalIdentity.client_id == client.id,
|
||||
UserExternalIdentity.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if existing_user_binding is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该学员已绑定其他外部账号")
|
||||
|
||||
identity = UserExternalIdentity(
|
||||
client_id=client.id,
|
||||
user_id=user.id,
|
||||
external_user_id=payload.externalUserId,
|
||||
phone_snapshot=payload.verifiedPhone,
|
||||
display_name_snapshot=payload.displayName,
|
||||
)
|
||||
db.add(identity)
|
||||
db.flush()
|
||||
return identity, user
|
||||
|
||||
@staticmethod
|
||||
def _validate_return_url(client: SsoClient, return_url: str | None) -> None:
|
||||
if not return_url:
|
||||
return
|
||||
allowed = _json_list(client.redirect_uris)
|
||||
if return_url.rstrip("/") not in {item.rstrip("/") for item in allowed}:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回跳地址不在应用白名单中")
|
||||
|
||||
@classmethod
|
||||
def _store_ticket(cls, payload: dict) -> str:
|
||||
redis = cls._required_redis()
|
||||
for _ in range(3):
|
||||
code = secrets.token_urlsafe(32)
|
||||
key = cls._code_key(code)
|
||||
try:
|
||||
stored = redis.set(
|
||||
key,
|
||||
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
||||
ex=cls.CODE_TTL_SECONDS,
|
||||
nx=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="单点登录服务暂时不可用",
|
||||
) from exc
|
||||
if stored:
|
||||
return code
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="授权码生成失败")
|
||||
|
||||
@classmethod
|
||||
def _consume_ticket(cls, code: str) -> dict:
|
||||
redis = cls._required_redis()
|
||||
key = cls._code_key(code)
|
||||
try:
|
||||
raw = redis.eval(
|
||||
"local v=redis.call('GET',KEYS[1]); if v then redis.call('DEL',KEYS[1]) end; return v",
|
||||
1,
|
||||
key,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="单点登录服务暂时不可用",
|
||||
) from exc
|
||||
if not raw:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="授权码无效、已使用或已过期")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="授权码数据无效") from exc
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _required_redis():
|
||||
redis = get_sync_redis_client()
|
||||
if redis is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="单点登录服务暂时不可用")
|
||||
return redis
|
||||
|
||||
@classmethod
|
||||
def _code_key(cls, code: str) -> str:
|
||||
return f"{cls.CODE_NAMESPACE}:{hashlib.sha256(code.encode()).hexdigest()}"
|
||||
|
||||
@staticmethod
|
||||
def _entry_url(db: Session, code: str) -> str:
|
||||
value = db.scalar(select(SystemConfig.config_value).where(SystemConfig.config_key == "sso_user_client_url"))
|
||||
base = (value or "").strip().rstrip("/")
|
||||
path = f"/?sso_code={quote(code)}"
|
||||
return f"{base}{path}" if base else path
|
||||
|
||||
|
||||
def _json_list(value: str | None) -> list[str]:
|
||||
try:
|
||||
decoded = json.loads(value or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [str(item) for item in decoded if str(item).strip()] if isinstance(decoded, list) else []
|
||||
|
||||
|
||||
def _db_now(db: Session) -> datetime:
|
||||
return db.scalar(select(func.now())) or datetime.now()
|
||||
|
||||
|
||||
def _int_value(value) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
Reference in New Issue
Block a user