feat: 增加可信应用免登录接入
This commit is contained in:
@@ -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