| """ |
| Linux Do OAuth service for LandPPT |
| Based on Linux Do Connect API: https://connect.linux.do |
| Uses Valkey for state storage with in-memory fallback |
| """ |
|
|
| import secrets |
| import hashlib |
| import time |
| import httpx |
| import logging |
| from urllib.parse import urlencode |
| from typing import Optional, Dict, Any |
| from sqlalchemy.orm import Session |
|
|
| from ..database.models import User |
| from ..core.config import app_config |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| LINUXDO_AUTHORIZE_URL = "https://connect.linux.do/oauth2/authorize" |
| LINUXDO_TOKEN_URL = "https://connect.linux.do/oauth2/token" |
| LINUXDO_USER_URL = "https://connect.linux.do/api/user" |
|
|
| |
| _oauth_states_fallback: Dict[str, Dict[str, Any]] = {} |
|
|
| |
| OAUTH_STATE_TTL = 1800 |
|
|
|
|
| def generate_state() -> str: |
| """Generate a random state parameter for CSRF protection.""" |
| return secrets.token_urlsafe(32) |
|
|
|
|
| def _get_system_oauth_config_sync() -> Dict[str, Any]: |
| """Best-effort read of system OAuth config from DB with app_config fallback.""" |
| config: Dict[str, Any] = {} |
| try: |
| from ..services.db_config_service import get_db_config_service |
|
|
| config_service = get_db_config_service() |
| config = config_service.get_all_config_sync(user_id=None) |
| except Exception as exc: |
| logger.warning("Failed to load Linux Do OAuth config from DB: %s", exc) |
| return config |
|
|
|
|
| async def store_oauth_state( |
| state: str, |
| redirect_url: str = "/dashboard", |
| invite_code: Optional[str] = None, |
| ) -> None: |
| """ |
| Store OAuth state for later verification. |
| Uses Valkey with in-memory fallback. |
| |
| Args: |
| state: The state parameter |
| redirect_url: URL to redirect after successful login |
| """ |
| state_data = { |
| "redirect_url": redirect_url, |
| "invite_code": (str(invite_code or "").strip().upper() or None), |
| "created_at": time.time() |
| } |
| |
| |
| try: |
| from ..services.cache_service import get_cache_service |
| cache = await get_cache_service() |
| |
| if cache.is_connected: |
| success = await cache.set_oauth_state("linuxdo", state, state_data, OAUTH_STATE_TTL) |
| if success: |
| logger.debug(f"Linux Do OAuth state stored in Valkey: {state[:8]}...") |
| return |
| except Exception as e: |
| logger.warning(f"Failed to store OAuth state in Valkey: {e}") |
| |
| |
| _oauth_states_fallback[state] = state_data |
| logger.debug(f"Linux Do OAuth state stored in memory (fallback): {state[:8]}...") |
| |
| |
| _cleanup_old_states_fallback() |
|
|
|
|
| async def get_and_consume_oauth_state(state: str) -> Optional[Dict[str, Any]]: |
| """ |
| Get and remove OAuth state data. |
| Checks Valkey first, then falls back to in-memory storage. |
| |
| Args: |
| state: The state parameter to look up |
| |
| Returns: |
| State data dict if found, None otherwise |
| """ |
| |
| try: |
| from ..services.cache_service import get_cache_service |
| cache = await get_cache_service() |
| |
| if cache.is_connected: |
| data = await cache.get_and_consume_oauth_state("linuxdo", state) |
| if data: |
| logger.debug(f"Linux Do OAuth state retrieved from Valkey: {state[:8]}...") |
| return data |
| except Exception as e: |
| logger.warning(f"Failed to get OAuth state from Valkey: {e}") |
| |
| |
| data = _oauth_states_fallback.pop(state, None) |
| if data: |
| logger.debug(f"Linux Do OAuth state retrieved from memory (fallback): {state[:8]}...") |
| |
| if time.time() - data.get("created_at", 0) > OAUTH_STATE_TTL: |
| logger.warning(f"OAuth state expired in memory fallback: {state[:8]}...") |
| return None |
| |
| return data |
|
|
|
|
| def _cleanup_old_states_fallback() -> None: |
| """Remove expired OAuth states from memory fallback.""" |
| current_time = time.time() |
| expired_states = [ |
| state for state, data in _oauth_states_fallback.items() |
| if current_time - data.get("created_at", 0) > OAUTH_STATE_TTL |
| ] |
| for state in expired_states: |
| _oauth_states_fallback.pop(state, None) |
|
|
|
|
| def get_callback_url() -> str: |
| """Get the OAuth callback URL based on current configuration.""" |
| system_config = _get_system_oauth_config_sync() |
| configured_callback = str(system_config.get("linuxdo_callback_url") or app_config.linuxdo_callback_url or "").strip() |
| if configured_callback: |
| return configured_callback |
| |
| |
| host = app_config.host |
| port = app_config.port |
| |
| if host == "0.0.0.0": |
| host = "localhost" |
| |
| return f"http://{host}:{port}/auth/linuxdo/callback" |
|
|
|
|
| def build_authorization_url(state: str) -> str: |
| """Build Linux Do OAuth authorization URL.""" |
| system_config = _get_system_oauth_config_sync() |
| params = { |
| "client_id": str(system_config.get("linuxdo_client_id") or app_config.linuxdo_client_id or "").strip(), |
| "redirect_uri": get_callback_url(), |
| "response_type": "code", |
| "scope": "user", |
| "state": state |
| } |
| |
| query_string = urlencode(params) |
| return f"{LINUXDO_AUTHORIZE_URL}?{query_string}" |
|
|
|
|
| async def exchange_code_for_token(code: str) -> Optional[str]: |
| """Exchange authorization code for access token.""" |
| system_config = _get_system_oauth_config_sync() |
| client_id = str(system_config.get("linuxdo_client_id") or app_config.linuxdo_client_id or "").strip() |
| client_secret = str(system_config.get("linuxdo_client_secret") or app_config.linuxdo_client_secret or "").strip() |
| try: |
| async with httpx.AsyncClient() as client: |
| response = await client.post( |
| LINUXDO_TOKEN_URL, |
| data={ |
| "client_id": client_id, |
| "client_secret": client_secret, |
| "code": code, |
| "redirect_uri": get_callback_url(), |
| "grant_type": "authorization_code" |
| }, |
| headers={ |
| "Content-Type": "application/x-www-form-urlencoded", |
| "Accept": "application/json" |
| }, |
| timeout=30.0 |
| ) |
| |
| if response.status_code != 200: |
| logger.error(f"Linux Do token exchange failed: {response.status_code} - {response.text}") |
| return None |
| |
| data = response.json() |
| |
| if "error" in data: |
| logger.error(f"Linux Do OAuth error: {data.get('error')} - {data.get('error_description')}") |
| return None |
| |
| return data.get("access_token") |
| |
| except Exception as e: |
| logger.error(f"Failed to exchange code for token: {e}") |
| return None |
|
|
|
|
| async def get_linuxdo_user_info(access_token: str) -> Optional[Dict[str, Any]]: |
| """Fetch Linux Do user information using access token.""" |
| try: |
| async with httpx.AsyncClient() as client: |
| response = await client.get( |
| LINUXDO_USER_URL, |
| headers={ |
| "Authorization": f"Bearer {access_token}", |
| "Accept": "application/json" |
| }, |
| timeout=30.0 |
| ) |
| |
| if response.status_code != 200: |
| logger.error(f"Failed to get Linux Do user: {response.status_code}") |
| return None |
| |
| user_data = response.json() |
| |
| return { |
| "id": str(user_data.get("id")), |
| "username": user_data.get("username"), |
| "name": user_data.get("name"), |
| "email": user_data.get("email"), |
| "avatar_url": user_data.get("avatar_url"), |
| "trust_level": user_data.get("trust_level"), |
| "active": user_data.get("active", True) |
| } |
| |
| except Exception as e: |
| logger.error(f"Failed to get Linux Do user info: {e}") |
| return None |
|
|
|
|
| def get_or_create_user_by_linuxdo( |
| db: Session, |
| linuxdo_id: str, |
| username: str, |
| email: Optional[str], |
| name: Optional[str], |
| avatar_url: Optional[str], |
| invite_code: Optional[str] = None, |
| ) -> tuple[Optional[User], bool, Optional[str]]: |
| """ |
| Get existing user or create new user from Linux Do OAuth. |
| |
| Logic: |
| 1. If user with linuxdo_id exists -> return that user |
| 2. If email exists and matches a local user -> link Linux Do to that user |
| 3. Otherwise -> create new user |
| """ |
| try: |
| |
| existing_linuxdo_user = db.query(User).filter(User.linuxdo_id == linuxdo_id).first() |
| if existing_linuxdo_user: |
| existing_linuxdo_user.last_login = time.time() |
| if avatar_url: |
| existing_linuxdo_user.avatar = avatar_url |
| db.commit() |
| return existing_linuxdo_user, False, None |
| |
| |
| if email: |
| existing_email_user = db.query(User).filter(User.email == email).first() |
| if existing_email_user: |
| existing_email_user.linuxdo_id = linuxdo_id |
| existing_email_user.oauth_provider = "linuxdo" |
| existing_email_user.last_login = time.time() |
| if avatar_url and not existing_email_user.avatar: |
| existing_email_user.avatar = avatar_url |
| db.commit() |
| logger.info(f"Linked Linux Do account {username} to existing user {existing_email_user.username}") |
| return existing_email_user, False, None |
|
|
| from ..services.community_service import community_service |
|
|
| try: |
| validated_invite = community_service.resolve_registration_invite(db, invite_code, "linuxdo") |
| except ValueError as exc: |
| return None, False, str(exc) |
| |
| |
| new_username = username |
| existing_username = db.query(User).filter(User.username == new_username).first() |
| if existing_username: |
| new_username = f"{username}_{secrets.token_hex(4)}" |
| |
| |
| default_credits = 0 |
| if app_config.enable_credits_system: |
| default_credits = app_config.default_credits_for_new_users |
| |
| new_user = User( |
| username=new_username, |
| email=email, |
| avatar=avatar_url, |
| linuxdo_id=linuxdo_id, |
| oauth_provider="linuxdo", |
| registration_channel="linuxdo", |
| is_active=True, |
| is_admin=False, |
| credits_balance=default_credits, |
| created_at=time.time(), |
| last_login=time.time() |
| ) |
| |
| new_user.password_hash = hashlib.sha256(secrets.token_bytes(32)).hexdigest() |
| |
| db.add(new_user) |
| db.flush() |
| if validated_invite is not None: |
| community_service.apply_invite_code_to_user(db, new_user, validated_invite, "linuxdo") |
| db.commit() |
| db.refresh(new_user) |
| |
| logger.info(f"Created new user from Linux Do OAuth: {new_username} (Linux Do: {username})") |
| return new_user, True, None |
| |
| except Exception as e: |
| logger.error(f"Failed to get or create user from Linux Do: {e}") |
| db.rollback() |
| return None, False, "LinuxDo 注册失败,请稍后重试" |
|
|
|
|
| def is_linuxdo_oauth_enabled() -> bool: |
| """Check if Linux Do OAuth is properly configured and enabled.""" |
| system_config = _get_system_oauth_config_sync() |
| enabled = bool(system_config.get("linuxdo_oauth_enabled", app_config.linuxdo_oauth_enabled)) |
| client_id = str(system_config.get("linuxdo_client_id") or app_config.linuxdo_client_id or "").strip() |
| client_secret = str(system_config.get("linuxdo_client_secret") or app_config.linuxdo_client_secret or "").strip() |
| return ( |
| enabled |
| and bool(client_id) |
| and bool(client_secret) |
| ) |
|
|