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

@@ -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