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

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