mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -74,10 +74,12 @@ class OAuthProviderBase(ABC):
|
|||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": redirect_uri,
|
||||||
"scope": self.get_effective_scopes(config),
|
|
||||||
"state": state,
|
"state": state,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
scopes = self.get_effective_scopes(config)
|
||||||
|
if scopes:
|
||||||
|
query["scope"] = scopes
|
||||||
|
|
||||||
return urlunparse(parsed._replace(query=urlencode(query)))
|
return urlunparse(parsed._replace(query=urlencode(query)))
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.services.auth.oauth.base import OAuthProviderBase
|
from src.services.auth.oauth.base import OAuthProviderBase
|
||||||
@@ -41,12 +46,50 @@ class LinuxDoOAuthProvider(OAuthProviderBase):
|
|||||||
authorization_url = "https://connect.linux.do/oauth2/authorize"
|
authorization_url = "https://connect.linux.do/oauth2/authorize"
|
||||||
token_url = "https://connect.linux.do/oauth2/token"
|
token_url = "https://connect.linux.do/oauth2/token"
|
||||||
userinfo_url = "https://connect.linux.do/api/user"
|
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
|
# LinuxDo 不需要 scope
|
||||||
default_scopes = ()
|
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:
|
async def exchange_code(self, config: OAuthProvider, code: str) -> OAuthToken:
|
||||||
url = self.get_effective_token_url(config)
|
|
||||||
client_secret = config.get_client_secret()
|
client_secret = config.get_client_secret()
|
||||||
if not client_secret:
|
if not client_secret:
|
||||||
raise OAuthFlowError("provider_unavailable", "client_secret 未配置")
|
raise OAuthFlowError("provider_unavailable", "client_secret 未配置")
|
||||||
@@ -56,15 +99,26 @@ class LinuxDoOAuthProvider(OAuthProviderBase):
|
|||||||
if not redirect_uri or not client_id:
|
if not redirect_uri or not client_id:
|
||||||
raise OAuthFlowError("provider_unavailable", "redirect_uri/client_id 未配置")
|
raise OAuthFlowError("provider_unavailable", "redirect_uri/client_id 未配置")
|
||||||
|
|
||||||
resp = await self._http_post_form(
|
candidate_urls = self._build_candidate_urls(
|
||||||
url,
|
self.get_effective_token_url(config), self.backup_token_url
|
||||||
data={
|
)
|
||||||
"grant_type": "authorization_code",
|
headers = {
|
||||||
"code": code,
|
"Authorization": self._build_basic_auth_header(client_id, client_secret),
|
||||||
"redirect_uri": redirect_uri,
|
"Accept": "application/json",
|
||||||
"client_id": client_id,
|
}
|
||||||
"client_secret": client_secret,
|
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:
|
if resp.status_code >= 400:
|
||||||
@@ -87,8 +141,15 @@ class LinuxDoOAuthProvider(OAuthProviderBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def get_user_info(self, config: OAuthProvider, access_token: str) -> OAuthUserInfo:
|
async def get_user_info(self, config: OAuthProvider, access_token: str) -> OAuthUserInfo:
|
||||||
url = self.get_effective_userinfo_url(config)
|
candidate_urls = self._build_candidate_urls(
|
||||||
resp = await self._http_get(url, headers={"Authorization": f"Bearer {access_token}"})
|
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:
|
if resp.status_code >= 400:
|
||||||
logger.warning("LinuxDo userinfo 获取失败: status={}", resp.status_code)
|
logger.warning("LinuxDo userinfo 获取失败: status={}", resp.status_code)
|
||||||
|
|||||||
@@ -46,148 +46,178 @@ class OAuthCallbackResult:
|
|||||||
refresh_token: str | None = field(default=None, repr=False)
|
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:
|
class OAuthService:
|
||||||
"""OAuth 核心业务服务(v1)。"""
|
"""OAuth 核心业务服务(v1)。"""
|
||||||
|
|
||||||
@staticmethod
|
@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)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
with get_db_context() as db:
|
with get_db_context() as db:
|
||||||
existing_link = (
|
original_expire_on_commit = getattr(db, "expire_on_commit", True)
|
||||||
db.query(UserOAuthLink)
|
db.expire_on_commit = False
|
||||||
.filter(
|
try:
|
||||||
UserOAuthLink.provider_type == provider_type,
|
existing_link = (
|
||||||
UserOAuthLink.provider_user_id == oauth_user.id,
|
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 existing_link:
|
if not linked_user or not linked_user.is_active or linked_user.is_deleted:
|
||||||
linked_user = db.query(User).filter(User.id == existing_link.user_id).first()
|
raise OAuthFlowError("account_disabled", "用户不存在或已禁用")
|
||||||
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
|
linked_user.last_login_at = now
|
||||||
existing_link.last_login_at = now
|
existing_link.last_login_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
db.expunge(linked_user)
|
assert linked_user.id is not None
|
||||||
return linked_user
|
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(
|
enable_registration = SystemConfigService.get_config(
|
||||||
db, "enable_registration", default=False
|
db, "enable_registration", default=False
|
||||||
)
|
)
|
||||||
if not enable_registration:
|
if not enable_registration:
|
||||||
raise OAuthFlowError("registration_disabled")
|
raise OAuthFlowError("registration_disabled")
|
||||||
|
|
||||||
email = oauth_user.email
|
email = oauth_user.email
|
||||||
if email:
|
if email:
|
||||||
if not OAuthService._validate_email_suffix(db, email):
|
if not OAuthService._validate_email_suffix(db, email):
|
||||||
raise OAuthFlowError("email_suffix_denied")
|
raise OAuthFlowError("email_suffix_denied")
|
||||||
|
|
||||||
existing_user = db.query(User).filter(User.email == email).first()
|
existing_user = db.query(User).filter(User.email == email).first()
|
||||||
if existing_user and not existing_user.is_deleted:
|
if existing_user and not existing_user.is_deleted:
|
||||||
if existing_user.auth_source == AuthSource.LOCAL:
|
if existing_user.auth_source == AuthSource.LOCAL:
|
||||||
raise OAuthFlowError("email_exists_local")
|
raise OAuthFlowError("email_exists_local")
|
||||||
if existing_user.auth_source == AuthSource.LDAP:
|
if existing_user.auth_source == AuthSource.LDAP:
|
||||||
raise OAuthFlowError("email_is_ldap")
|
raise OAuthFlowError("email_is_ldap")
|
||||||
raise OAuthFlowError("email_is_oauth")
|
raise OAuthFlowError("email_is_oauth")
|
||||||
|
|
||||||
base_username = (
|
base_username = (
|
||||||
oauth_user.username
|
oauth_user.username
|
||||||
or (email.split("@", 1)[0] if email else None)
|
or (email.split("@", 1)[0] if email else None)
|
||||||
or f"user_{uuid.uuid4().hex[:8]}"
|
or f"user_{uuid.uuid4().hex[:8]}"
|
||||||
)
|
)
|
||||||
default_initial_gift = SystemConfigService.get_config(
|
default_initial_gift = SystemConfigService.get_config(
|
||||||
db, "default_user_initial_gift_usd", default=None
|
db, "default_user_initial_gift_usd", default=None
|
||||||
)
|
)
|
||||||
|
|
||||||
user: User | None = None
|
user: User | None = None
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
for _ in range(3):
|
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:
|
try:
|
||||||
username = OAuthService._generate_unique_username(db, base_username)
|
link = UserOAuthLink(
|
||||||
user = User(
|
user_id=user.id,
|
||||||
email=email,
|
provider_type=provider_type,
|
||||||
email_verified=bool(oauth_user.email_verified) if email else False,
|
provider_user_id=oauth_user.id,
|
||||||
username=username,
|
provider_username=oauth_user.username,
|
||||||
password_hash=None,
|
provider_email=email,
|
||||||
auth_source=AuthSource.OAUTH,
|
extra_data=oauth_user.raw,
|
||||||
role=UserRole.USER,
|
linked_at=now,
|
||||||
is_active=True,
|
|
||||||
last_login_at=now,
|
last_login_at=now,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(link)
|
||||||
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.commit()
|
||||||
db.refresh(user)
|
|
||||||
last_error = None
|
|
||||||
break
|
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
last_error = e
|
constraint = OAuthService._get_constraint_name(e)
|
||||||
except Exception as e:
|
if constraint == "uq_oauth_provider_user":
|
||||||
db.rollback()
|
existing_link = (
|
||||||
last_error = e
|
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:
|
assert user.role is not None
|
||||||
raise OAuthFlowError("provider_error", "user_create_failed")
|
return OAuthAuthenticatedUser(
|
||||||
|
|
||||||
assert user.id is not None
|
|
||||||
try:
|
|
||||||
link = UserOAuthLink(
|
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
provider_type=provider_type,
|
email=user.email,
|
||||||
provider_user_id=oauth_user.id,
|
role=user.role,
|
||||||
provider_username=oauth_user.username,
|
created_at=user.created_at,
|
||||||
provider_email=email,
|
|
||||||
extra_data=oauth_user.raw,
|
|
||||||
linked_at=now,
|
|
||||||
last_login_at=now,
|
|
||||||
)
|
)
|
||||||
db.add(link)
|
finally:
|
||||||
db.commit()
|
db.expire_on_commit = original_expire_on_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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_bind_sync(
|
def _handle_bind_sync(
|
||||||
@@ -747,13 +777,18 @@ class OAuthService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert user.id is not None
|
db_user = db.query(User).filter(User.id == user.user_id).first()
|
||||||
assert user.role is not None
|
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())
|
session_id = str(uuid.uuid4())
|
||||||
access_token = AuthService.create_access_token(
|
access_token = AuthService.create_access_token(
|
||||||
data={
|
data={
|
||||||
"user_id": user.id,
|
"user_id": user.user_id,
|
||||||
"role": user.role.value,
|
"role": user.role.value,
|
||||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
@@ -761,7 +796,7 @@ class OAuthService:
|
|||||||
)
|
)
|
||||||
refresh_token = AuthService.create_refresh_token(
|
refresh_token = AuthService.create_refresh_token(
|
||||||
data={
|
data={
|
||||||
"user_id": user.id,
|
"user_id": user.user_id,
|
||||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"jti": str(uuid.uuid4()),
|
"jti": str(uuid.uuid4()),
|
||||||
@@ -775,7 +810,7 @@ class OAuthService:
|
|||||||
)
|
)
|
||||||
SessionService.create_session(
|
SessionService.create_session(
|
||||||
db,
|
db,
|
||||||
user=user,
|
user=db_user,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
expires_at=AuthService.get_refresh_token_expiry(),
|
expires_at=AuthService.get_refresh_token_expiry(),
|
||||||
@@ -793,14 +828,13 @@ class OAuthService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def _handle_login(
|
async def _handle_login(
|
||||||
db: Session, *, config: OAuthProvider, oauth_user: OAuthUserInfo
|
db: Session, *, config: OAuthProvider, oauth_user: OAuthUserInfo
|
||||||
) -> User:
|
) -> OAuthAuthenticatedUser:
|
||||||
user = await run_in_threadpool(
|
user = await run_in_threadpool(
|
||||||
OAuthService._handle_login_sync,
|
OAuthService._handle_login_sync,
|
||||||
config.provider_type,
|
config.provider_type,
|
||||||
oauth_user,
|
oauth_user,
|
||||||
)
|
)
|
||||||
assert user.id is not None
|
await UserCacheService.invalidate_user_cache(user.user_id, user.email)
|
||||||
await UserCacheService.invalidate_user_cache(user.id, user.email)
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
138
tests/services/test_linuxdo_oauth_provider.py
Normal file
138
tests/services/test_linuxdo_oauth_provider.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.auth.oauth.providers.linuxdo import LinuxDoOAuthProvider
|
||||||
|
|
||||||
|
|
||||||
|
def _make_config() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
client_id="client-id",
|
||||||
|
redirect_uri="https://api.example.com/api/oauth/linuxdo/callback",
|
||||||
|
authorization_url_override=None,
|
||||||
|
token_url_override=None,
|
||||||
|
userinfo_url_override=None,
|
||||||
|
scopes=None,
|
||||||
|
get_client_secret=lambda: "client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_linuxdo_authorization_url_omits_empty_scope() -> None:
|
||||||
|
provider = LinuxDoOAuthProvider()
|
||||||
|
url = provider.get_authorization_url(_make_config(), "state-1")
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
|
||||||
|
assert parsed.netloc == "connect.linux.do"
|
||||||
|
assert "scope" not in params
|
||||||
|
assert params["state"] == ["state-1"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_linuxdo_exchange_code_uses_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
provider = LinuxDoOAuthProvider()
|
||||||
|
config = _make_config()
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def _fake_post_form(
|
||||||
|
url: str,
|
||||||
|
data: dict[str, str],
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 5.0,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
captured["url"] = url
|
||||||
|
captured["data"] = data
|
||||||
|
captured["headers"] = headers or {}
|
||||||
|
captured["timeout_seconds"] = timeout_seconds
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"access_token": "access-1", "token_type": "bearer"},
|
||||||
|
request=httpx.Request("POST", url),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(provider, "_http_post_form", _fake_post_form)
|
||||||
|
|
||||||
|
token = await provider.exchange_code(config, "code-1")
|
||||||
|
|
||||||
|
assert token.access_token == "access-1"
|
||||||
|
assert captured["url"] == provider.token_url
|
||||||
|
assert captured["data"] == {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": "code-1",
|
||||||
|
"redirect_uri": config.redirect_uri,
|
||||||
|
}
|
||||||
|
assert captured["headers"] == {
|
||||||
|
"Authorization": provider._build_basic_auth_header("client-id", "client-secret"),
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_linuxdo_exchange_code_falls_back_to_backup_endpoint(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
provider = LinuxDoOAuthProvider()
|
||||||
|
config = _make_config()
|
||||||
|
called_urls: list[str] = []
|
||||||
|
|
||||||
|
async def _fake_post_form(
|
||||||
|
url: str,
|
||||||
|
data: dict[str, str],
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 5.0,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
called_urls.append(url)
|
||||||
|
if len(called_urls) == 1:
|
||||||
|
raise httpx.ConnectError("network down", request=httpx.Request("POST", url))
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"access_token": "access-2", "token_type": "bearer"},
|
||||||
|
request=httpx.Request("POST", url),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(provider, "_http_post_form", _fake_post_form)
|
||||||
|
|
||||||
|
token = await provider.exchange_code(config, "code-2")
|
||||||
|
|
||||||
|
assert token.access_token == "access-2"
|
||||||
|
assert called_urls == [provider.token_url, provider.backup_token_url]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_linuxdo_userinfo_falls_back_to_backup_endpoint(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
provider = LinuxDoOAuthProvider()
|
||||||
|
config = _make_config()
|
||||||
|
called_urls: list[str] = []
|
||||||
|
|
||||||
|
async def _fake_get(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 5.0,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
called_urls.append(url)
|
||||||
|
if len(called_urls) == 1:
|
||||||
|
raise httpx.ConnectError("network down", request=httpx.Request("GET", url))
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"id": 42, "username": "neo", "email": "Neo@Linux.Do"},
|
||||||
|
request=httpx.Request("GET", url),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(provider, "_http_get", _fake_get)
|
||||||
|
|
||||||
|
user = await provider.get_user_info(config, "access-token")
|
||||||
|
|
||||||
|
assert user.id == "42"
|
||||||
|
assert user.username == "neo"
|
||||||
|
assert user.email == "neo@linux.do"
|
||||||
|
assert called_urls == [provider.userinfo_url, provider.backup_userinfo_url]
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from src.core.enums import AuthSource
|
from src.core.enums import AuthSource, UserRole
|
||||||
|
from src.models.database import Base, OAuthProvider, User, UserOAuthLink
|
||||||
from src.services.auth.oauth.service import OAuthService
|
from src.services.auth.oauth.service import OAuthService
|
||||||
from src.services.auth.oauth.state import OAuthStateData
|
from src.services.auth.oauth.state import OAuthStateData
|
||||||
|
|
||||||
@@ -127,3 +131,79 @@ async def test_handle_callback_allows_bind_state_without_device_id(
|
|||||||
parsed = urlparse(result.redirect_url)
|
parsed = urlparse(result.redirect_url)
|
||||||
assert result.refresh_token is None
|
assert result.refresh_token is None
|
||||||
assert parse_qs(parsed.query)["oauth_bound"] == ["GitHub"]
|
assert parse_qs(parsed.query)["oauth_bound"] == ["GitHub"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_login_sync_returns_user_snapshot_outside_db_session(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
engine,
|
||||||
|
tables=[User.__table__, OAuthProvider.__table__, UserOAuthLink.__table__],
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
provider = OAuthProvider(
|
||||||
|
provider_type="linuxdo",
|
||||||
|
display_name="Linux Do",
|
||||||
|
client_id="client-id",
|
||||||
|
redirect_uri="https://api.example.com/api/oauth/linuxdo/callback",
|
||||||
|
frontend_callback_url="https://app.example.com/auth/callback",
|
||||||
|
is_enabled=True,
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
email="user@example.com",
|
||||||
|
email_verified=True,
|
||||||
|
username="tester",
|
||||||
|
auth_source=AuthSource.OAUTH,
|
||||||
|
role=UserRole.USER,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
db.add(provider)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
user_id = str(user.id)
|
||||||
|
db.add(
|
||||||
|
UserOAuthLink(
|
||||||
|
user_id=user_id,
|
||||||
|
provider_type="linuxdo",
|
||||||
|
provider_user_id="linuxdo-user-1",
|
||||||
|
provider_username="tester",
|
||||||
|
provider_email="user@example.com",
|
||||||
|
extra_data={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _fake_get_db_context():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.services.auth.oauth.service.get_db_context", _fake_get_db_context)
|
||||||
|
|
||||||
|
snapshot = OAuthService._handle_login_sync(
|
||||||
|
"linuxdo",
|
||||||
|
SimpleNamespace(
|
||||||
|
id="linuxdo-user-1",
|
||||||
|
username="tester",
|
||||||
|
email="user@example.com",
|
||||||
|
email_verified=True,
|
||||||
|
raw={},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.user_id == user_id
|
||||||
|
assert snapshot.email == "user@example.com"
|
||||||
|
assert snapshot.role == UserRole.USER
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
|
|||||||
Reference in New Issue
Block a user