-
Notifications
You must be signed in to change notification settings - Fork 571
Refactor OAuth implementation and enhance account linking features #2775
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
74e4986
67124f9
f75a08a
695ff84
cdcc8da
61f54f2
35cf40b
77bea00
420e8a9
b60e0eb
e0a14b7
bb3569e
7c2629a
97d96e9
7747865
93f7ebf
ea91d9a
0fc6745
3c75afb
d6e3dfb
1196e6d
fd2d187
e2d423c
67da260
6915778
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,295 @@ | ||
| import logging | ||
|
|
||
| from fastapi import APIRouter, Header, HTTPException | ||
| from fastapi.responses import JSONResponse, RedirectResponse | ||
| from http import HTTPStatus | ||
| from typing import Optional | ||
|
|
||
| from consts.exceptions import OAuthLinkError, OAuthProviderError, UnauthorizedError | ||
| from consts.oauth_providers import get_all_provider_definitions | ||
| from services.oauth_service import ( | ||
| create_or_update_oauth_account, | ||
| ensure_user_tenant_exists, | ||
| exchange_code_for_provider_token, | ||
| get_authorize_url, | ||
| get_enabled_providers, | ||
| get_provider_user_info, | ||
| list_linked_accounts, | ||
| unlink_account, | ||
| ) | ||
| from utils.auth_utils import ( | ||
| calculate_expires_at, | ||
| generate_session_jwt, | ||
| get_current_user_id, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
| router = APIRouter(prefix="/user/oauth", tags=["oauth"]) | ||
|
|
||
|
|
||
| @router.get("/providers") | ||
| async def get_providers(): | ||
| providers = get_enabled_providers() | ||
| return JSONResponse( | ||
| status_code=HTTPStatus.OK, | ||
| content={"message": "success", "data": providers}, | ||
| ) | ||
|
|
||
|
|
||
| @router.get("/authorize") | ||
| async def authorize(provider: str): | ||
| try: | ||
| url = get_authorize_url(provider) | ||
| return RedirectResponse(url=url, status_code=HTTPStatus.FOUND) | ||
| except OAuthProviderError as e: | ||
| raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) | ||
| except Exception as e: | ||
| logger.error(f"OAuth authorize failed: {e}") | ||
| raise HTTPException( | ||
| status_code=HTTPStatus.INTERNAL_SERVER_ERROR, | ||
| detail="OAuth authorization failed", | ||
| ) | ||
|
|
||
|
|
||
| @router.get("/link") | ||
| async def link(provider: str, authorization: Optional[str] = Header(None)): | ||
|
Check failure on line 55 in backend/apps/oauth_app.py
|
||
| if not authorization: | ||
| raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in") | ||
|
Check failure on line 57 in backend/apps/oauth_app.py
|
||
|
|
||
| try: | ||
| user_id, _ = get_current_user_id(authorization) | ||
| url = get_authorize_url(provider, link_user_id=user_id) | ||
| return RedirectResponse(url=url, status_code=HTTPStatus.FOUND) | ||
| except UnauthorizedError: | ||
| raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in") | ||
| except OAuthProviderError as e: | ||
| raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) | ||
| except Exception as e: | ||
| logger.error(f"OAuth link failed: {e}") | ||
| raise HTTPException( | ||
| status_code=HTTPStatus.INTERNAL_SERVER_ERROR, | ||
| detail="OAuth link failed", | ||
| ) | ||
|
|
||
|
|
||
| @router.get("/callback") | ||
| async def callback( | ||
|
Check failure on line 76 in backend/apps/oauth_app.py
|
||
| provider: str, | ||
| code: str = "", | ||
| state: str = "", | ||
| error: Optional[str] = None, | ||
| error_description: Optional[str] = None, | ||
| ): | ||
| if error: | ||
| return JSONResponse( | ||
| status_code=HTTPStatus.BAD_REQUEST, | ||
| content={ | ||
| "message": "OAuth provider returned an error", | ||
| "data": { | ||
| "oauth_error": error, | ||
| "oauth_error_description": error_description or "Unknown error", | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| if not code: | ||
| return JSONResponse( | ||
| status_code=HTTPStatus.BAD_REQUEST, | ||
| content={ | ||
| "message": "No authorization code received", | ||
| "data": { | ||
| "oauth_error": "no_code", | ||
| "oauth_error_description": "No authorization code received", | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| if provider not in get_all_provider_definitions(): | ||
| return JSONResponse( | ||
| status_code=HTTPStatus.BAD_REQUEST, | ||
| content={ | ||
| "message": "Unsupported OAuth provider", | ||
| "data": { | ||
| "oauth_error": "unsupported_provider", | ||
| "oauth_error_description": f"Provider '{provider}' is not supported", | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| from services.oauth_service import parse_state | ||
|
|
||
| state_info = parse_state(state) | ||
| link_user_id = state_info.get("link_user_id", "") | ||
|
|
||
| try: | ||
| token_data = exchange_code_for_provider_token(provider, code) | ||
| provider_access_token = token_data["access_token"] | ||
|
|
||
| user_info = get_provider_user_info( | ||
| provider, | ||
| provider_access_token, | ||
| openid=token_data.get("openid", ""), | ||
| ) | ||
|
|
||
| provider_user_id = user_info["id"] | ||
| email = user_info["email"] | ||
| username = user_info["username"] | ||
|
|
||
| from utils.auth_utils import get_supabase_admin_client | ||
| from services.oauth_service import get_oauth_account_by_provider | ||
|
|
||
| if link_user_id: | ||
| supabase_user_id = link_user_id | ||
| else: | ||
| # First check if this OAuth account is already bound to a user | ||
| existing_binding = get_oauth_account_by_provider(provider, provider_user_id) | ||
| if existing_binding: | ||
| supabase_user_id = existing_binding["user_id"] | ||
| else: | ||
| # No binding found, search/create user by email in Supabase | ||
| admin_client = get_supabase_admin_client() | ||
| if not admin_client: | ||
| raise RuntimeError("Supabase admin client not available") | ||
|
|
||
| supabase_user_id = None | ||
| page = 1 | ||
| while True: | ||
| users_resp = admin_client.auth.admin.list_users( | ||
| page=page, per_page=100 | ||
| ) | ||
| users = users_resp if len(users_resp) > 0 else [] | ||
| if not users: | ||
| break | ||
| for u in users: | ||
| if u.email and u.email.lower() == email.lower(): | ||
| supabase_user_id = u.id | ||
| break | ||
| if supabase_user_id: | ||
| break | ||
| if len(users) < 100: | ||
| break | ||
| page += 1 | ||
|
|
||
| if not supabase_user_id: | ||
| if not email: | ||
| email = f"{provider}_{provider_user_id}@oauth.nexent" | ||
| create_resp = admin_client.auth.admin.create_user( | ||
| { | ||
| "email": email, | ||
| "email_confirm": True, | ||
| "user_metadata": { | ||
| "full_name": username, | ||
| "provider": provider, | ||
| }, | ||
| } | ||
| ) | ||
| supabase_user_id = create_resp.user.id | ||
|
|
||
| ensure_user_tenant_exists(user_id=supabase_user_id, email=email) | ||
|
|
||
| create_or_update_oauth_account( | ||
| user_id=supabase_user_id, | ||
| provider=provider, | ||
| provider_user_id=provider_user_id, | ||
| email=email, | ||
| username=username, | ||
| ) | ||
|
|
||
| expiry_seconds = 3600 | ||
| jwt_token = generate_session_jwt(supabase_user_id, expires_in=expiry_seconds) | ||
| expires_at = calculate_expires_at(jwt_token) | ||
|
|
||
| return JSONResponse( | ||
| status_code=HTTPStatus.OK, | ||
| content={ | ||
| "message": "OAuth login successful", | ||
| "data": { | ||
| "user": { | ||
| "id": str(supabase_user_id), | ||
| "email": email, | ||
| }, | ||
| "session": { | ||
| "access_token": jwt_token, | ||
| "refresh_token": "", | ||
| "expires_at": expires_at, | ||
| "expires_in_seconds": expiry_seconds, | ||
| }, | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| except Exception as e: | ||
| logger.error(f"OAuth callback failed for provider={provider}: {e}") | ||
|
Check warning on line 222 in backend/apps/oauth_app.py
|
||
| return JSONResponse( | ||
| status_code=HTTPStatus.INTERNAL_SERVER_ERROR, | ||
| content={ | ||
| "message": "OAuth login failed", | ||
| "data": { | ||
| "oauth_error": "callback_failed", | ||
| "oauth_error_description": "OAuth login failed", | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
|
|
||
| @router.get("/accounts") | ||
| async def get_accounts(authorization: Optional[str] = Header(None)): | ||
|
Check failure on line 236 in backend/apps/oauth_app.py
|
||
| if not authorization: | ||
| raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in") | ||
|
|
||
| try: | ||
| user_id, _ = get_current_user_id(authorization) | ||
| accounts = list_linked_accounts(user_id) | ||
| return JSONResponse( | ||
| status_code=HTTPStatus.OK, | ||
| content={"message": "success", "data": accounts}, | ||
| ) | ||
| except UnauthorizedError: | ||
| raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in") | ||
| except Exception as e: | ||
| logger.error(f"Failed to get OAuth accounts: {e}") | ||
| raise HTTPException( | ||
| status_code=HTTPStatus.INTERNAL_SERVER_ERROR, | ||
| detail="Failed to get OAuth accounts", | ||
| ) | ||
|
|
||
|
|
||
| @router.delete("/accounts/{provider}") | ||
| async def delete_account(provider: str, authorization: Optional[str] = Header(None)): | ||
|
Check failure on line 258 in backend/apps/oauth_app.py
|
||
| if not authorization: | ||
| raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in") | ||
|
|
||
| try: | ||
| user_id, _ = get_current_user_id(authorization) | ||
|
|
||
| has_password_auth = False | ||
| from utils.auth_utils import get_supabase_admin_client | ||
|
|
||
| admin_client = get_supabase_admin_client() | ||
| if admin_client: | ||
| try: | ||
| user_resp = admin_client.auth.admin.get_user_by_id(user_id) | ||
| user_metadata = getattr(user_resp.user, "user_metadata", {}) or {} | ||
| signup_provider = user_metadata.get("provider", "email") | ||
| has_password_auth = signup_provider == "email" | ||
| except Exception as e: | ||
| logger.warning(f"Failed to check user identities for {user_id}: {e}") | ||
|
|
||
| unlink_account(user_id, provider, has_password_auth=has_password_auth) | ||
| return JSONResponse( | ||
| status_code=HTTPStatus.OK, | ||
| content={ | ||
| "message": "success", | ||
| "data": {"provider": provider, "unlinked": True}, | ||
| }, | ||
| ) | ||
| except OAuthLinkError as e: | ||
| raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) | ||
| except UnauthorizedError: | ||
| raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in") | ||
| except Exception as e: | ||
| logger.error(f"Failed to unlink OAuth account: {e}") | ||
| raise HTTPException( | ||
| status_code=HTTPStatus.INTERNAL_SERVER_ERROR, | ||
| detail="Failed to unlink OAuth account", | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
package import 风格建议统一,放文件头