refactor(oauth): LinuxDo 备用端点回退、Basic Auth 认证,修复 session 外访问 ORM 对象

- LinuxDo provider: token/userinfo 请求增加 backup 端点自动回退
- LinuxDo provider: token 请求改用 HTTP Basic Auth 认证
- 授权 URL 构建: scope 为空时不再发送该参数
- OAuthService: 引入 OAuthAuthenticatedUser 快照,避免 DB session 关闭后访问 ORM 对象
- OAuthService: _handle_login_sync 设置 expire_on_commit=False 防止属性过期
- 新增 LinuxDo provider 单元测试(Basic Auth、端点回退)
- 新增 _handle_login_sync 返回快照的集成测试
This commit is contained in:
fawney19
2026-03-19 20:32:33 +08:00
parent f573110725
commit e4ebd5cca1
5 changed files with 456 additions and 141 deletions

View File

@@ -74,10 +74,12 @@ class OAuthProviderBase(ABC):
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": self.get_effective_scopes(config),
"state": state,
}
)
scopes = self.get_effective_scopes(config)
if scopes:
query["scope"] = scopes
return urlunparse(parsed._replace(query=urlencode(query)))

View File

@@ -1,6 +1,11 @@
from __future__ import annotations
import base64
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
import httpx
from src.core.logger import logger
from src.services.auth.oauth.base import OAuthProviderBase
@@ -41,12 +46,50 @@ class LinuxDoOAuthProvider(OAuthProviderBase):
authorization_url = "https://connect.linux.do/oauth2/authorize"
token_url = "https://connect.linux.do/oauth2/token"
userinfo_url = "https://connect.linux.do/api/user"
backup_token_url = "https://connect.linuxdo.org/oauth2/token"
backup_userinfo_url = "https://connect.linuxdo.org/api/user"
# LinuxDo 不需要 scope
default_scopes = ()
@staticmethod
def _build_basic_auth_header(client_id: str, client_secret: str) -> str:
credentials = f"{client_id}:{client_secret}".encode("utf-8")
return f"Basic {base64.b64encode(credentials).decode('ascii')}"
@staticmethod
def _build_candidate_urls(primary_url: str, backup_url: str) -> list[str]:
parsed = urlparse(primary_url)
host = (parsed.hostname or "").lower().rstrip(".")
backup_path = urlparse(backup_url).path
urls = [primary_url]
if parsed.scheme == "https" and host == "connect.linux.do" and parsed.path == backup_path:
urls.append(backup_url)
return urls
@staticmethod
async def _request_with_fallback(
candidate_urls: list[str],
request_fn: Callable[[str], Awaitable[httpx.Response]],
error_code: str,
label: str,
) -> httpx.Response:
resp: httpx.Response | None = None
for idx, url in enumerate(candidate_urls):
try:
resp = await request_fn(url)
break
except httpx.HTTPError as exc:
if idx < len(candidate_urls) - 1:
logger.warning("LinuxDo {} 端点不可达,尝试备用端点: {} ({})", label, url, exc)
continue
logger.warning("LinuxDo {} 请求失败: {} ({})", label, url, exc)
raise OAuthFlowError(error_code, "transport_error") from exc
if resp is None:
raise OAuthFlowError(error_code, "no_response")
return resp
async def exchange_code(self, config: OAuthProvider, code: str) -> OAuthToken:
url = self.get_effective_token_url(config)
client_secret = config.get_client_secret()
if not client_secret:
raise OAuthFlowError("provider_unavailable", "client_secret 未配置")
@@ -56,15 +99,26 @@ class LinuxDoOAuthProvider(OAuthProviderBase):
if not redirect_uri or not client_id:
raise OAuthFlowError("provider_unavailable", "redirect_uri/client_id 未配置")
resp = await self._http_post_form(
url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret,
},
candidate_urls = self._build_candidate_urls(
self.get_effective_token_url(config), self.backup_token_url
)
headers = {
"Authorization": self._build_basic_auth_header(client_id, client_secret),
"Accept": "application/json",
}
resp = await self._request_with_fallback(
candidate_urls,
lambda url: self._http_post_form(
url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
headers=headers,
),
error_code="token_exchange_failed",
label="token",
)
if resp.status_code >= 400:
@@ -87,8 +141,15 @@ class LinuxDoOAuthProvider(OAuthProviderBase):
)
async def get_user_info(self, config: OAuthProvider, access_token: str) -> OAuthUserInfo:
url = self.get_effective_userinfo_url(config)
resp = await self._http_get(url, headers={"Authorization": f"Bearer {access_token}"})
candidate_urls = self._build_candidate_urls(
self.get_effective_userinfo_url(config), self.backup_userinfo_url
)
resp = await self._request_with_fallback(
candidate_urls,
lambda url: self._http_get(url, headers={"Authorization": f"Bearer {access_token}"}),
error_code="userinfo_fetch_failed",
label="userinfo",
)
if resp.status_code >= 400:
logger.warning("LinuxDo userinfo 获取失败: status={}", resp.status_code)

View File

@@ -46,148 +46,178 @@ class OAuthCallbackResult:
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) -> User:
def _handle_login_sync(provider_type: str, oauth_user: OAuthUserInfo) -> OAuthAuthenticatedUser:
now = datetime.now(timezone.utc)
with get_db_context() as db:
existing_link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
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()
)
.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", "用户不存在或已禁用")
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()
db.expunge(linked_user)
return linked_user
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")
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")
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")
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
)
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):
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:
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,
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(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.add(link)
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
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")
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(
assert user.role is not None
return OAuthAuthenticatedUser(
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,
email=user.email,
role=user.role,
created_at=user.created_at,
)
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()
db.expunge(existing_user)
return existing_user
raise OAuthFlowError("oauth_already_bound")
raise OAuthFlowError("provider_error", "link_create_failed")
db.expunge(user)
return user
finally:
db.expire_on_commit = original_expire_on_commit
@staticmethod
def _handle_bind_sync(
@@ -747,13 +777,18 @@ class OAuthService:
)
)
assert user.id is not None
assert user.role is not None
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.id,
"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,
@@ -761,7 +796,7 @@ class OAuthService:
)
refresh_token = AuthService.create_refresh_token(
data={
"user_id": user.id,
"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()),
@@ -775,7 +810,7 @@ class OAuthService:
)
SessionService.create_session(
db,
user=user,
user=db_user,
session_id=session_id,
refresh_token=refresh_token,
expires_at=AuthService.get_refresh_token_expiry(),
@@ -793,14 +828,13 @@ class OAuthService:
@staticmethod
async def _handle_login(
db: Session, *, config: OAuthProvider, oauth_user: OAuthUserInfo
) -> User:
) -> OAuthAuthenticatedUser:
user = await run_in_threadpool(
OAuthService._handle_login_sync,
config.provider_type,
oauth_user,
)
assert user.id is not None
await UserCacheService.invalidate_user_cache(user.id, user.email)
await UserCacheService.invalidate_user_cache(user.user_id, user.email)
return user
@staticmethod