11"""认证授权相关API接口"""
2- from fastapi import APIRouter , Depends , HTTPException , status
2+ from __future__ import annotations
3+
4+ import json
5+ import secrets
6+ from typing import cast
7+ from urllib .parse import urlencode
8+
9+ import httpx
10+ from fastapi import APIRouter , Depends , HTTPException , Request , status
11+ from fastapi .responses import RedirectResponse
312from fastapi .security import OAuth2PasswordRequestForm
413from sqlalchemy .ext .asyncio import AsyncSession
5- from typing import cast
614
715from app .core .auth import create_access_token
8- from app .core .security import verify_password
16+ from app .core .config import get_settings
17+ from app .core .security import hash_password , verify_password
918from app .db import UserRepository
1019from app .db .session import get_db
1120from app .schemas .auth import Token # type: ignore[import-not-found]
1221
1322router = APIRouter ()
23+ settings = get_settings ()
24+
25+ GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
26+ GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
27+ GITHUB_USER_API = "https://api.github.com/user"
28+ GITHUB_EMAILS_API = "https://api.github.com/user/emails"
29+ GITHUB_STATE_COOKIE = "github_oauth_state"
30+ GITHUB_STATE_TTL = 600
1431
1532
1633@router .post (
@@ -99,4 +116,189 @@ async def login_for_access_token(
99116 )
100117
101118 token = create_access_token (subject = cast (str , getattr (user , "email" , "" )))
102- return Token (access_token = token )
119+ return Token (access_token = token )
120+
121+
122+ @router .get ("/github/login" )
123+ async def github_login (request : Request , next : str | None = None ):
124+ client_id = settings .github_client_id
125+ client_secret = settings .github_client_secret
126+ if not client_id or not client_secret :
127+ raise HTTPException (status_code = status .HTTP_503_SERVICE_UNAVAILABLE , detail = "GitHub OAuth 未配置" )
128+
129+ nonce = secrets .token_urlsafe (32 )
130+ cookie_payload = json .dumps (
131+ {
132+ "nonce" : nonce ,
133+ "next" : _sanitize_next_path (next ),
134+ }
135+ )
136+
137+ callback_url = str (request .url_for ("github_callback" ))
138+ params = {
139+ "client_id" : client_id ,
140+ "redirect_uri" : callback_url ,
141+ "scope" : "read:user user:email" ,
142+ "state" : nonce ,
143+ "allow_signup" : "true" ,
144+ }
145+ authorize_url = f"{ GITHUB_AUTHORIZE_URL } ?{ urlencode (params )} "
146+ response = RedirectResponse (authorize_url , status_code = status .HTTP_302_FOUND )
147+ response .set_cookie (
148+ GITHUB_STATE_COOKIE ,
149+ cookie_payload ,
150+ max_age = GITHUB_STATE_TTL ,
151+ httponly = True ,
152+ secure = _is_cookie_secure (),
153+ samesite = "lax" ,
154+ )
155+ return response
156+
157+
158+ @router .get ("/github/callback" , name = "github_callback" )
159+ async def github_callback (
160+ request : Request ,
161+ code : str | None = None ,
162+ state : str | None = None ,
163+ db : AsyncSession = Depends (get_db ),
164+ ):
165+ cookie_payload = request .cookies .get (GITHUB_STATE_COOKIE )
166+ if not cookie_payload :
167+ return _oauth_error_redirect ("Missing OAuth state cookie." )
168+
169+ try :
170+ payload = json .loads (cookie_payload )
171+ except json .JSONDecodeError :
172+ return _oauth_error_redirect ("Invalid OAuth state payload." )
173+
174+ expected_state = payload .get ("nonce" )
175+ next_path = _sanitize_next_path (payload .get ("next" ))
176+ if not code or not state or expected_state != state :
177+ return _oauth_error_redirect ("OAuth state mismatch." , next_path = next_path )
178+
179+ try :
180+ callback_url = str (request .url_for ("github_callback" ))
181+ token_data = await _exchange_github_code_for_token (code , callback_url )
182+ access_token = token_data .get ("access_token" )
183+ if not access_token :
184+ raise RuntimeError ("GitHub did not return an access token." )
185+
186+ github_user , primary_email = await _fetch_github_profile (access_token )
187+ if not primary_email :
188+ raise RuntimeError ("未能获取 GitHub 邮箱,请在 GitHub 账户开启公开邮箱或授权 email scope。" )
189+
190+ github_user_id = str (github_user .get ("id" ))
191+ repo = UserRepository (db )
192+ user = await repo .get_by_oauth_account ("github" , github_user_id )
193+
194+ if user is None :
195+ user = await repo .get_by_email (primary_email )
196+ if user :
197+ user = await repo .update (
198+ user ,
199+ {
200+ "oauth_provider" : "github" ,
201+ "oauth_account_id" : github_user_id ,
202+ "avatar_url" : user .avatar_url or github_user .get ("avatar_url" ),
203+ },
204+ )
205+ else :
206+ hashed_password = hash_password (secrets .token_urlsafe (32 ))
207+ user = await repo .create (
208+ email = primary_email ,
209+ hashed_password = hashed_password ,
210+ full_name = github_user .get ("name" ) or github_user .get ("login" ),
211+ avatar_url = github_user .get ("avatar_url" ),
212+ oauth_provider = "github" ,
213+ oauth_account_id = github_user_id ,
214+ )
215+ else :
216+ updates : dict [str , str | None ] = {}
217+ if not user .avatar_url and github_user .get ("avatar_url" ):
218+ updates ["avatar_url" ] = github_user .get ("avatar_url" )
219+ updates ["oauth_provider" ] = "github"
220+ updates ["oauth_account_id" ] = github_user_id
221+ user = await repo .update (user , updates )
222+
223+ await db .commit ()
224+ await db .refresh (user )
225+
226+ token = create_access_token (subject = cast (str , getattr (user , "email" , "" )))
227+ redirect_target = _build_frontend_redirect (token = token , next_path = next_path )
228+ response = RedirectResponse (redirect_target , status_code = status .HTTP_302_FOUND )
229+ response .delete_cookie (GITHUB_STATE_COOKIE )
230+ return response
231+ except Exception as exc : # pragma: no cover - defensive
232+ return _oauth_error_redirect (str (exc ), next_path = next_path )
233+
234+
235+ def _sanitize_next_path (value : str | None ) -> str :
236+ if not value or not value .startswith ("/" ):
237+ return "/"
238+ return value
239+
240+
241+ def _is_cookie_secure () -> bool :
242+ return settings .environment not in {"local" , "development" }
243+
244+
245+ async def _exchange_github_code_for_token (code : str , redirect_uri : str ) -> dict [str , str ]:
246+ client_id = settings .github_client_id
247+ client_secret = settings .github_client_secret
248+ if not client_id or not client_secret :
249+ raise RuntimeError ("GitHub OAuth 未配置。" )
250+
251+ data = {
252+ "client_id" : client_id ,
253+ "client_secret" : client_secret ,
254+ "code" : code ,
255+ "redirect_uri" : redirect_uri ,
256+ }
257+
258+ async with httpx .AsyncClient (timeout = 15.0 , headers = {"Accept" : "application/json" }) as client :
259+ response = await client .post (GITHUB_TOKEN_URL , data = data )
260+ response .raise_for_status ()
261+ return response .json ()
262+
263+
264+ async def _fetch_github_profile (access_token : str ) -> tuple [dict [str , str ], str | None ]:
265+ headers = {
266+ "Accept" : "application/json" ,
267+ "Authorization" : f"Bearer { access_token } " ,
268+ }
269+ async with httpx .AsyncClient (timeout = 15.0 ) as client :
270+ user_resp = await client .get (GITHUB_USER_API , headers = headers )
271+ user_resp .raise_for_status ()
272+ user_data = user_resp .json ()
273+ email = user_data .get ("email" )
274+ if not email :
275+ emails_resp = await client .get (GITHUB_EMAILS_API , headers = headers )
276+ emails_resp .raise_for_status ()
277+ emails = emails_resp .json ()
278+ email = next (
279+ (
280+ item .get ("email" )
281+ for item in emails
282+ if item .get ("primary" ) and item .get ("verified" )
283+ ),
284+ None ,
285+ ) or next ((item .get ("email" ) for item in emails if item .get ("verified" )), None )
286+ return user_data , email
287+
288+
289+ def _build_frontend_redirect (* , token : str | None , next_path : str ) -> str :
290+ params = {"next" : _sanitize_next_path (next_path )}
291+ if token :
292+ params ["token" ] = token
293+ params ["provider" ] = "github"
294+ return f"{ settings .frontend_oauth_redirect_url } ?{ urlencode (params )} "
295+
296+
297+ def _oauth_error_redirect (message : str , * , next_path : str = "/" ) -> RedirectResponse :
298+ params = {
299+ "error" : message ,
300+ "next" : _sanitize_next_path (next_path ),
301+ }
302+ response = RedirectResponse (f"{ settings .frontend_oauth_redirect_url } ?{ urlencode (params )} " , status_code = status .HTTP_302_FOUND )
303+ response .delete_cookie (GITHUB_STATE_COOKIE )
304+ return response
0 commit comments