Files
Aether/_deprecated_py_src/services/auth/oauth/service.py

1070 lines
42 KiB
Python
Raw Normal View History

import re
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from fastapi import HTTPException, status
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.clients.redis_client import get_redis_client
from src.core.enums import AuthSource, UserRole
from src.core.exceptions import ConfirmationRequiredException, InvalidRequestException
from src.core.logger import logger
from src.core.modules import get_module_registry
from src.database import get_db_context
from src.models.database import OAuthProvider, User, UserOAuthLink
from src.services.auth.oauth.base import OAuthProviderBase
from src.services.auth.oauth.models import OAuthFlowError, OAuthUserInfo
from src.services.auth.oauth.registry import get_oauth_provider_registry
from src.services.auth.oauth.state import consume_oauth_state, create_oauth_state
from src.services.auth.service import AuthService
from src.services.auth.session_service import SessionService
from src.services.cache.user_cache import UserCacheService
from src.services.system.config import SystemConfigService
OAUTH_CONFIG_TEST_RUST_ONLY_DETAILS = "OAuth 配置测试仅支持 Rust executor"
@dataclass(frozen=True)
class OAuthCallbackResult:
redirect_url: str
refresh_token: str | None = field(default=None, repr=False)
@dataclass(frozen=True)
class OAuthAuthenticatedUser:
user_id: str
email: str | None
role: UserRole
created_at: datetime | None
class OAuthService:
"""OAuth 核心业务服务v1"""
@staticmethod
def _handle_login_sync(provider_type: str, oauth_user: OAuthUserInfo) -> OAuthAuthenticatedUser:
now = datetime.now(timezone.utc)
with get_db_context() as db:
original_expire_on_commit = getattr(db, "expire_on_commit", True)
db.expire_on_commit = False
try:
existing_link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing_link:
linked_user = db.query(User).filter(User.id == existing_link.user_id).first()
if not linked_user or not linked_user.is_active or linked_user.is_deleted:
raise OAuthFlowError("account_disabled", "用户不存在或已禁用")
linked_user.last_login_at = now
existing_link.last_login_at = now
db.commit()
assert linked_user.id is not None
assert linked_user.role is not None
return OAuthAuthenticatedUser(
user_id=linked_user.id,
email=linked_user.email,
role=linked_user.role,
created_at=linked_user.created_at,
)
enable_registration = SystemConfigService.get_config(
db, "enable_registration", default=False
)
if not enable_registration:
raise OAuthFlowError("registration_disabled")
email = oauth_user.email
if email:
if not OAuthService._validate_email_suffix(db, email):
raise OAuthFlowError("email_suffix_denied")
existing_user = db.query(User).filter(User.email == email).first()
if existing_user and not existing_user.is_deleted:
if existing_user.auth_source == AuthSource.LOCAL:
raise OAuthFlowError("email_exists_local")
if existing_user.auth_source == AuthSource.LDAP:
raise OAuthFlowError("email_is_ldap")
raise OAuthFlowError("email_is_oauth")
base_username = (
oauth_user.username
or (email.split("@", 1)[0] if email else None)
or f"user_{uuid.uuid4().hex[:8]}"
)
default_initial_gift = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
user: User | None = None
last_error: Exception | None = None
for _ in range(3):
try:
username = OAuthService._generate_unique_username(db, base_username)
user = User(
email=email,
email_verified=bool(oauth_user.email_verified) if email else False,
username=username,
password_hash=None,
auth_source=AuthSource.OAUTH,
role=UserRole.USER,
is_active=True,
last_login_at=now,
)
db.add(user)
db.flush()
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=user,
initial_gift_usd=default_initial_gift,
unlimited=False,
description="OAuth 注册初始赠款",
)
db.commit()
db.refresh(user)
last_error = None
break
except IntegrityError as e:
db.rollback()
last_error = e
except Exception as e:
db.rollback()
last_error = e
if last_error is not None or user is None:
raise OAuthFlowError("provider_error", "user_create_failed")
assert user.id is not None
try:
link = UserOAuthLink(
user_id=user.id,
provider_type=provider_type,
provider_user_id=oauth_user.id,
provider_username=oauth_user.username,
provider_email=email,
extra_data=oauth_user.raw,
linked_at=now,
last_login_at=now,
)
db.add(link)
db.commit()
except IntegrityError as e:
db.rollback()
constraint = OAuthService._get_constraint_name(e)
if constraint == "uq_oauth_provider_user":
existing_link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing_link:
existing_user = (
db.query(User).filter(User.id == existing_link.user_id).first()
)
if (
existing_user
and existing_user.is_active
and not existing_user.is_deleted
):
existing_user.last_login_at = now
existing_link.last_login_at = now
db.commit()
assert existing_user.id is not None
assert existing_user.role is not None
return OAuthAuthenticatedUser(
user_id=existing_user.id,
email=existing_user.email,
role=existing_user.role,
created_at=existing_user.created_at,
)
raise OAuthFlowError("oauth_already_bound")
raise OAuthFlowError("provider_error", "link_create_failed")
assert user.role is not None
return OAuthAuthenticatedUser(
user_id=user.id,
email=user.email,
role=user.role,
created_at=user.created_at,
)
finally:
db.expire_on_commit = original_expire_on_commit
@staticmethod
def _handle_bind_sync(
user_id: str, provider_type: str, oauth_user: OAuthUserInfo
) -> UserOAuthLink:
now = datetime.now(timezone.utc)
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active or user.is_deleted:
raise OAuthFlowError("user_not_found")
if user.auth_source == AuthSource.LDAP:
raise OAuthFlowError("ldap_no_oauth")
link = UserOAuthLink(
user_id=user.id,
provider_type=provider_type,
provider_user_id=oauth_user.id,
provider_username=oauth_user.username,
provider_email=oauth_user.email,
extra_data=oauth_user.raw,
linked_at=now,
)
try:
db.add(link)
db.commit()
db.refresh(link)
db.expunge(link)
return link
except IntegrityError as e:
db.rollback()
constraint = OAuthService._get_constraint_name(e)
if constraint == "uq_oauth_provider_user":
existing = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing and existing.user_id == user.id:
db.expunge(existing)
return existing
raise OAuthFlowError("oauth_already_bound")
if constraint == "uq_user_oauth_provider":
raise OAuthFlowError("already_bound_provider")
raise OAuthFlowError("provider_error", "bind_failed")
@staticmethod
def _upsert_provider_config_sync(provider_type: str, data: Any) -> OAuthProvider:
with get_db_context() as db:
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
raise InvalidRequestException("不支持的 provider_type")
OAuthService._validate_provider_config(provider, data)
row = (
db.query(OAuthProvider).filter(OAuthProvider.provider_type == provider_type).first()
)
creating = row is None
if not row:
row = OAuthProvider(provider_type=provider_type)
db.add(row)
if row.is_enabled and data.is_enabled is False:
affected = OAuthService._check_provider_disable_safety(db, provider_type)
if affected and not getattr(data, "force", False):
raise ConfirmationRequiredException(
message=f"禁用该 Provider 会导致 {len(affected)} 个用户无法登录",
affected_count=len(affected),
action="disable_oauth_provider",
)
row.display_name = data.display_name
row.client_id = data.client_id
row.authorization_url_override = data.authorization_url_override
row.token_url_override = data.token_url_override
row.userinfo_url_override = data.userinfo_url_override
row.scopes = data.scopes
row.redirect_uri = data.redirect_uri
row.frontend_callback_url = data.frontend_callback_url
row.attribute_mapping = data.attribute_mapping
row.extra_config = data.extra_config
row.is_enabled = data.is_enabled
if data.client_secret is not None:
secret_value = data.client_secret.strip()
if secret_value == "__CLEAR__":
row.client_secret_encrypted = None
elif secret_value:
row.set_client_secret(secret_value)
try:
db.commit()
db.refresh(row)
except Exception:
db.rollback()
raise
db.expunge(row)
if creating:
logger.info("OAuth provider 配置已创建: {}", provider_type)
else:
logger.info("OAuth provider 配置已更新: {}", provider_type)
return row
@staticmethod
def _delete_provider_config_sync(provider_type: str) -> None:
with get_db_context() as db:
row = (
db.query(OAuthProvider).filter(OAuthProvider.provider_type == provider_type).first()
)
if not row:
raise InvalidRequestException("Provider 配置不存在")
if row.is_enabled:
affected = OAuthService._check_provider_disable_safety(db, provider_type)
if affected:
raise InvalidRequestException(
f"删除该 Provider 会导致部分用户无法登录(数量: {len(affected)}),已阻止操作"
)
db.delete(row)
@staticmethod
def _unbind_provider_sync(user_id: str, provider_type: str) -> None:
with get_db_context() as db:
OAuthService._require_module_active(db)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise InvalidRequestException("用户不存在")
if user.auth_source == AuthSource.LDAP:
raise HTTPException(status_code=403, detail="LDAP 用户不允许解绑 OAuth")
link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.user_id == user.id, UserOAuthLink.provider_type == provider_type
)
.first()
)
if not link:
raise InvalidRequestException("未绑定该 Provider")
total_links = (
db.query(func.count(UserOAuthLink.id))
.filter(UserOAuthLink.user_id == user.id)
.scalar()
or 0
)
if user.auth_source == AuthSource.OAUTH and total_links <= 1:
raise InvalidRequestException("OAUTH 用户必须至少保留一个 OAuth 绑定")
if user.auth_source == AuthSource.LOCAL and not user.password_hash and total_links <= 1:
raise InvalidRequestException("请先设置密码后再解绑")
from src.core.modules.hooks import AUTH_CHECK_EXCLUSIVE_MODE, get_hook_dispatcher
is_exclusive = get_hook_dispatcher().dispatch_sync(AUTH_CHECK_EXCLUSIVE_MODE, db=db)
if (
is_exclusive
and user.auth_source == AuthSource.LOCAL
and user.role != UserRole.ADMIN
):
if total_links <= 1:
raise InvalidRequestException("当前处于 LDAP 专属模式,解绑后将无法登录")
db.delete(link)
@staticmethod
def _require_module_active(db: Session) -> None:
registry = get_module_registry()
if not registry.is_active("oauth", db):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth 模块未启用"
)
@staticmethod
def _get_provider_impl(provider_type: str) -> OAuthProviderBase | None:
registry = get_oauth_provider_registry()
registry.discover_providers()
provider = registry.get_provider(provider_type)
return provider
@staticmethod
def _get_provider_config(db: Session, provider_type: str) -> OAuthProvider:
row = db.query(OAuthProvider).filter(OAuthProvider.provider_type == provider_type).first()
if not row:
raise InvalidRequestException("Provider 配置不存在")
return row
@staticmethod
def _get_enabled_provider_config(db: Session, provider_type: str) -> OAuthProvider:
row = OAuthService._get_provider_config(db, provider_type)
if not row.is_enabled:
raise OAuthFlowError("provider_disabled", "provider 未启用")
return row
@staticmethod
def _build_frontend_error_redirect(
frontend_callback_url: str, *, error_code: str, error_detail: str = ""
) -> str:
parsed = urlparse(frontend_callback_url)
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
query["error_code"] = error_code
if error_detail:
query["error_detail"] = (error_detail[:200]).strip()
return urlunparse(parsed._replace(query=urlencode(query), fragment=""))
@staticmethod
def _build_frontend_bind_success_redirect(frontend_callback_url: str, display_name: str) -> str:
parsed = urlparse(frontend_callback_url)
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
query["oauth_bound"] = display_name
return urlunparse(parsed._replace(query=urlencode(query), fragment=""))
@staticmethod
def _build_frontend_login_success_redirect(
frontend_callback_url: str, *, access_token: str
) -> str:
parsed = urlparse(frontend_callback_url)
fragment = urlencode(
{
"access_token": access_token,
"token_type": "bearer",
"expires_in": 86400,
}
)
# fragment 不会被发送回后端,适配当前 localStorage 登录态方案
return urlunparse(parsed._replace(fragment=fragment))
@staticmethod
async def list_public_providers(db: Session) -> list[dict[str, str]]:
registry = get_module_registry()
if not registry.is_active("oauth", db):
return []
supported = get_oauth_provider_registry()
supported.discover_providers()
rows = (
db.query(OAuthProvider)
.filter(OAuthProvider.is_enabled.is_(True))
.order_by(OAuthProvider.provider_type.asc())
.all()
)
result: list[dict[str, str]] = []
for row in rows:
provider_type_value = row.provider_type
if not provider_type_value:
continue
provider_type_str = str(provider_type_value)
if supported.get_provider(provider_type_str) is None:
continue
display_name = row.display_name or provider_type_str
result.append({"provider_type": provider_type_str, "display_name": str(display_name)})
return result
@staticmethod
async def build_login_authorize_url(
db: Session, provider_type: str, client_device_id: str | None = None
) -> str:
OAuthService._require_module_active(db)
normalized_device_id = (
SessionService._normalize_device_id(client_device_id) if client_device_id else None
)
if not normalized_device_id:
raise HTTPException(status_code=400, detail="缺少或无效的设备标识")
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
raise HTTPException(status_code=404, detail="不支持的 OAuth provider")
try:
config = OAuthService._get_enabled_provider_config(db, provider_type)
except OAuthFlowError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=exc.error_code
)
redis = await get_redis_client(require_redis=True)
if redis is None:
raise HTTPException(status_code=503, detail="Redis 不可用")
state = await create_oauth_state(
redis,
provider_type=provider_type,
action="login",
user_id=None,
client_device_id=normalized_device_id,
)
return provider.get_authorization_url(config, state)
@staticmethod
async def build_bind_authorize_url(
db: Session,
user: User,
provider_type: str,
client_device_id: str | None = None,
) -> str:
OAuthService._require_module_active(db)
if user.auth_source == AuthSource.LDAP:
raise HTTPException(status_code=403, detail="LDAP 用户不允许绑定 OAuth")
normalized_device_id: str | None = None
if client_device_id is not None:
normalized_device_id = SessionService._normalize_device_id(client_device_id)
if not normalized_device_id:
raise HTTPException(status_code=400, detail="缺少或无效的设备标识")
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
raise HTTPException(status_code=404, detail="不支持的 OAuth provider")
try:
config = OAuthService._get_enabled_provider_config(db, provider_type)
except OAuthFlowError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=exc.error_code
)
redis = await get_redis_client(require_redis=True)
if redis is None:
raise HTTPException(status_code=503, detail="Redis 不可用")
state = await create_oauth_state(
redis,
provider_type=provider_type,
action="bind",
user_id=user.id,
client_device_id=normalized_device_id,
)
return provider.get_authorization_url(config, state)
@staticmethod
def _sanitize_username(raw: str | None) -> str:
if not raw or not raw.strip():
return f"user_{uuid.uuid4().hex[:8]}"
cleaned = re.sub(r"[^a-zA-Z0-9_-]", "_", raw.strip())
cleaned = re.sub(r"_+", "_", cleaned).strip("_")
if cleaned and cleaned[0].isdigit():
cleaned = f"u_{cleaned}"
# 预留后缀空间,避免后续重试超长
max_len = 90
if len(cleaned) > max_len:
cleaned = cleaned[:max_len]
return cleaned or f"user_{uuid.uuid4().hex[:8]}"
@staticmethod
def _generate_unique_username(db: Session, base: str, max_retries: int = 3) -> str:
base = OAuthService._sanitize_username(base)
candidates = [base]
for i in range(max_retries - 1):
suffix_len = 4 if i == 0 else 8
candidates.append(f"{base}_{uuid.uuid4().hex[:suffix_len]}")
for cand in candidates:
exists = db.query(User).filter(User.username == cand).first()
if not exists:
return cand
raise ValueError("无法生成唯一用户名")
@staticmethod
def _validate_email_suffix(db: Session, email: str) -> bool:
mode = SystemConfigService.get_config(db, "email_suffix_mode", default="none")
if mode == "none":
return True
suffix_list = SystemConfigService.get_config(db, "email_suffix_list", default=[])
if isinstance(suffix_list, str):
suffix_list = [s.strip().lower() for s in suffix_list.split(",") if s.strip()]
if not suffix_list:
return True
if "@" not in email:
return False
email_suffix = email.split("@", 1)[1].lower()
if mode == "whitelist":
return email_suffix in suffix_list
if mode == "blacklist":
return email_suffix not in suffix_list
return True
@staticmethod
def _get_constraint_name(err: IntegrityError) -> str | None:
orig = getattr(err, "orig", None)
diag = getattr(orig, "diag", None)
name = getattr(diag, "constraint_name", None)
return str(name) if name else None
@staticmethod
async def handle_callback(
*,
db: Session,
provider_type: str,
state: str,
code: str | None,
error: str | None,
error_description: str | None,
client_ip: str | None,
user_agent: str,
headers: dict[str, str],
) -> OAuthCallbackResult:
OAuthService._require_module_active(db)
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
raise HTTPException(status_code=404, detail="不支持的 OAuth provider")
config = OAuthService._get_provider_config(db, provider_type)
frontend_callback_url = config.frontend_callback_url
if not frontend_callback_url:
# 无法重定向到前端时,直接返回 500配置错误
raise HTTPException(status_code=500, detail="frontend_callback_url 未配置")
frontend_callback_url = str(frontend_callback_url)
# display_name 用于 bind 成功 toast兜底为 provider_type
display_name = str(config.display_name or provider_type)
# provider 被禁用时仍引导回前端(给出明确提示)
if not config.is_enabled:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="provider_disabled"
)
)
# provider 侧 error
if error:
code_map = "authorization_denied" if error == "access_denied" else "provider_error"
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url,
error_code=code_map,
error_detail=error_description or error,
)
)
if not code or not state:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_callback"
)
)
# 一次性消费 state
try:
redis = await get_redis_client(require_redis=True)
if redis is None:
raise RuntimeError("redis unavailable")
state_data = await consume_oauth_state(redis, state)
except Exception as exc:
logger.warning("OAuth state 消费失败: {}", exc)
state_data = None
if not state_data:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_state"
)
)
normalized_device_id = None
if state_data.client_device_id:
normalized_device_id = SessionService._normalize_device_id(state_data.client_device_id)
if not normalized_device_id:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_state"
)
)
if state_data.action == "login" and not normalized_device_id:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_state"
)
)
if state_data.provider_type != provider_type:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_state"
)
)
if state_data.action not in ("login", "bind"):
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_state"
)
)
if state_data.action == "bind" and not state_data.user_id:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_bind_state"
)
)
try:
token = await provider.exchange_code(config, code)
oauth_user = await provider.get_user_info(config, token.access_token)
except OAuthFlowError as exc:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code=exc.error_code, error_detail=exc.detail
)
)
except Exception as exc:
logger.warning("OAuth callback 处理失败: {}", exc)
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="provider_error"
)
)
if state_data.action == "bind":
try:
await OAuthService._handle_bind(
db, user_id=state_data.user_id or "", config=config, oauth_user=oauth_user
)
except OAuthFlowError as exc:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code=exc.error_code, error_detail=exc.detail
)
)
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_bind_success_redirect(
frontend_callback_url, display_name
)
)
# login
try:
user = await OAuthService._handle_login(db, config=config, oauth_user=oauth_user)
except OAuthFlowError as exc:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code=exc.error_code, error_detail=exc.detail
)
)
db_user = db.query(User).filter(User.id == user.user_id).first()
if not db_user or not db_user.is_active or db_user.is_deleted:
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="account_disabled"
)
)
session_id = str(uuid.uuid4())
access_token = AuthService.create_access_token(
data={
"user_id": user.user_id,
"role": user.role.value,
"created_at": user.created_at.isoformat() if user.created_at else None,
"session_id": session_id,
}
)
refresh_token = AuthService.create_refresh_token(
data={
"user_id": user.user_id,
"created_at": user.created_at.isoformat() if user.created_at else None,
"session_id": session_id,
"jti": str(uuid.uuid4()),
}
)
client_context = SessionService.build_client_context(
client_device_id=normalized_device_id,
client_ip=client_ip,
user_agent=user_agent,
headers=headers,
)
SessionService.create_session(
db,
user=db_user,
session_id=session_id,
refresh_token=refresh_token,
expires_at=AuthService.get_refresh_token_expiry(),
client=client_context,
)
db.commit()
return OAuthCallbackResult(
redirect_url=OAuthService._build_frontend_login_success_redirect(
frontend_callback_url, access_token=access_token
),
refresh_token=refresh_token,
)
@staticmethod
async def _handle_login(
db: Session, *, config: OAuthProvider, oauth_user: OAuthUserInfo
) -> OAuthAuthenticatedUser:
user = await run_in_threadpool(
OAuthService._handle_login_sync,
config.provider_type,
oauth_user,
)
await UserCacheService.invalidate_user_cache(user.user_id, user.email)
return user
@staticmethod
async def _handle_bind(
db: Session, *, user_id: str, config: OAuthProvider, oauth_user: OAuthUserInfo
) -> UserOAuthLink:
return await run_in_threadpool(
OAuthService._handle_bind_sync,
user_id,
config.provider_type,
oauth_user,
)
@staticmethod
async def list_bindable_providers(db: Session, user: User) -> list[dict[str, str]]:
OAuthService._require_module_active(db)
if user.auth_source == AuthSource.LDAP:
return []
supported = get_oauth_provider_registry()
supported.discover_providers()
enabled_rows = (
db.query(OAuthProvider)
.filter(OAuthProvider.is_enabled.is_(True))
.order_by(OAuthProvider.provider_type.asc())
.all()
)
linked_types = {
provider_type
for (provider_type,) in db.query(UserOAuthLink.provider_type)
.filter(UserOAuthLink.user_id == user.id)
.all()
}
result: list[dict[str, str]] = []
for row in enabled_rows:
provider_type_value = row.provider_type
if not provider_type_value:
continue
provider_type_str = str(provider_type_value)
if provider_type_str in linked_types:
continue
if supported.get_provider(provider_type_str) is None:
continue
display_name = row.display_name or provider_type_str
result.append({"provider_type": provider_type_str, "display_name": str(display_name)})
return result
@staticmethod
async def list_user_links(db: Session, user: User) -> list[dict[str, Any]]:
OAuthService._require_module_active(db)
rows = (
db.query(UserOAuthLink, OAuthProvider)
.join(OAuthProvider, UserOAuthLink.provider_type == OAuthProvider.provider_type)
.filter(UserOAuthLink.user_id == user.id)
.order_by(UserOAuthLink.linked_at.desc())
.all()
)
result: list[dict[str, Any]] = []
for link, provider in rows:
result.append(
{
"provider_type": link.provider_type,
"display_name": provider.display_name,
"provider_username": link.provider_username,
"provider_email": link.provider_email,
"linked_at": link.linked_at.isoformat() if link.linked_at else None,
"last_login_at": link.last_login_at.isoformat() if link.last_login_at else None,
"provider_enabled": bool(provider.is_enabled),
}
)
return result
@staticmethod
def _check_provider_disable_safety(db: Session, provider_type: str) -> list[str]:
"""
v1 简化版防锁号检查
- 只检查活跃用户is_active && !is_deleted
- OAUTH 用户禁用后必须仍有其它启用的 OAuth provider 绑定
- LOCAL 用户ldap_exclusive=true 且非 admin 同上
"""
from src.core.modules.hooks import AUTH_CHECK_EXCLUSIVE_MODE, get_hook_dispatcher
ldap_exclusive = get_hook_dispatcher().dispatch_sync(AUTH_CHECK_EXCLUSIVE_MODE, db=db)
users = (
db.query(User.id, User.auth_source, User.role)
.join(UserOAuthLink, User.id == UserOAuthLink.user_id)
.filter(
User.is_active.is_(True),
User.is_deleted.is_(False),
UserOAuthLink.provider_type == provider_type,
)
.all()
)
affected: list[str] = []
for user_id, auth_source, role in users:
other_enabled_count = (
db.query(func.count(UserOAuthLink.id))
.join(OAuthProvider, UserOAuthLink.provider_type == OAuthProvider.provider_type)
.filter(
UserOAuthLink.user_id == user_id,
UserOAuthLink.provider_type != provider_type,
OAuthProvider.is_enabled.is_(True),
)
.scalar()
or 0
)
locked = False
if auth_source == AuthSource.OAUTH:
if other_enabled_count == 0:
locked = True
elif auth_source == AuthSource.LOCAL and ldap_exclusive:
is_admin = role == UserRole.ADMIN
if not is_admin and other_enabled_count == 0:
locked = True
if locked:
affected.append(str(user_id))
return affected
@staticmethod
async def upsert_provider_config(db: Session, provider_type: str, data: Any) -> OAuthProvider:
return await run_in_threadpool(
OAuthService._upsert_provider_config_sync, provider_type, data
)
@staticmethod
async def delete_provider_config(db: Session, provider_type: str) -> None:
await run_in_threadpool(OAuthService._delete_provider_config_sync, provider_type)
@staticmethod
def _validate_provider_config(provider: OAuthProviderBase, data: Any) -> None:
# frontend_callback_url 校验:必须绝对 URLpath 以 /auth/callback 结尾(允许 basePath
OAuthService._validate_frontend_callback_url(data.frontend_callback_url)
# redirect_uri允许本地 http其余建议 httpsv1仅做基本校验
OAuthService._validate_redirect_uri(data.redirect_uri)
# 覆盖端点:必须 https 且 hostname 命中 provider 白名单
for field_name in (
"authorization_url_override",
"token_url_override",
"userinfo_url_override",
):
value = getattr(data, field_name)
if value:
OAuthService._validate_url_override(provider, value)
@staticmethod
def _validate_frontend_callback_url(url: str) -> None:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
raise InvalidRequestException("frontend_callback_url 必须是绝对 URL")
if parsed.scheme not in {"http", "https"}:
raise InvalidRequestException("frontend_callback_url scheme 必须是 http/https")
path = (parsed.path or "").rstrip("/")
if not path.endswith("/auth/callback"):
raise InvalidRequestException("frontend_callback_url 路径必须以 /auth/callback 结尾")
@staticmethod
def _validate_redirect_uri(url: str) -> None:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
raise InvalidRequestException("redirect_uri 必须是绝对 URL")
if parsed.scheme not in {"http", "https"}:
raise InvalidRequestException("redirect_uri scheme 必须是 http/https")
@staticmethod
def _validate_url_override(provider: OAuthProviderBase, url: str) -> None:
parsed = urlparse(url)
if parsed.scheme != "https" or not parsed.netloc:
raise InvalidRequestException("端点覆盖必须是 https 绝对 URL")
host = (parsed.hostname or "").lower().rstrip(".")
allowed = False
for domain in provider.allowed_domains:
d = domain.lower().rstrip(".")
if host == d or host.endswith(f".{d}"):
allowed = True
break
if not allowed:
raise InvalidRequestException("端点覆盖不在允许的域名白名单中")
@staticmethod
async def test_provider_config(db: Session, provider_type: str) -> dict[str, Any]:
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
return {
"authorization_url_reachable": False,
"token_url_reachable": False,
"secret_status": "unknown",
"details": "provider 未安装/不可用",
}
return {
"authorization_url_reachable": False,
"token_url_reachable": False,
"secret_status": "unsupported",
"details": OAUTH_CONFIG_TEST_RUST_ONLY_DETAILS,
}
@staticmethod
async def test_provider_config_with_data(
provider_type: str,
client_id: str,
client_secret: str | None,
authorization_url_override: str | None,
token_url_override: str | None,
redirect_uri: str,
) -> dict[str, Any]:
"""使用传入的表单数据测试配置,而非从数据库读取"""
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
return {
"authorization_url_reachable": False,
"token_url_reachable": False,
"secret_status": "unknown",
"details": "provider 未安装/不可用",
}
return {
"authorization_url_reachable": False,
"token_url_reachable": False,
"secret_status": "unsupported" if client_secret else "not_provided",
"details": OAUTH_CONFIG_TEST_RUST_ONLY_DETAILS,
}
@staticmethod
async def unbind_provider(db: Session, user: User, provider_type: str) -> None:
await run_in_threadpool(OAuthService._unbind_provider_sync, user.id, provider_type)
if user.id is not None:
await UserCacheService.invalidate_user_cache(user.id, user.email)