Compare commits

...

2 Commits

9 changed files with 242 additions and 27 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
from app.models.user import User from app.models.user import User
from app.auth.deps import get_current_user from auth.deps import get_current_user
from fastapi import Request from fastapi import Request
from strawberry.fastapi import BaseContext from strawberry.fastapi import BaseContext
from strawberry.dataloader import DataLoader from strawberry.dataloader import DataLoader
+1 -1
View File
@@ -1,5 +1,5 @@
import strawberry import strawberry
from app.auth.jwt import create_jwt_token, create_refresh_token from auth.jwt import create_jwt_token, create_refresh_token
@strawberry.type @strawberry.type
class AuthResponse: class AuthResponse:
+22 -4
View File
@@ -1,10 +1,12 @@
from fastapi import FastAPI from fastapi import FastAPI, Depends, HTTPException
from strawberry import Schema from strawberry import Schema
from strawberry.fastapi import GraphQLRouter from strawberry.fastapi import GraphQLRouter
from app.auth import router as auth_router from auth import router as auth_router, get_user_by_id, get_current_user
from app.db import engine
from app.models import Base from app.db import engine, SessionLocal
from app.models import Base, User
from app.utils.format import get_image_url
# 导入 GraphQL 组件 # 导入 GraphQL 组件
from app.graphql.queries import Query from app.graphql.queries import Query
@@ -27,6 +29,22 @@ graphql_router = GraphQLRouter(schema, context_getter=get_context)
app.include_router(auth_router) # 保留现有的 auth 路由供 Google OAuth 回调使用 app.include_router(auth_router) # 保留现有的 auth 路由供 Google OAuth 回调使用
app.include_router(graphql_router, prefix="/graphql") # 挂载 GraphQL 终点,前端以后只需要请求 /graphql app.include_router(graphql_router, prefix="/graphql") # 挂载 GraphQL 终点,前端以后只需要请求 /graphql
def override_get_user_by_id(user_id: str = Depends(get_current_user)):
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"email": user.email,
"name": user.name,
"avatar": get_image_url(user.avatar),
}
finally:
db.close()
app.dependency_overrides[get_user_by_id] = override_get_user_by_id
# test code # test code
for route in app.routes: for route in app.routes:
methods = getattr(route, "methods", "N/A") methods = getattr(route, "methods", "N/A")
+18 -18
View File
@@ -1,6 +1,5 @@
import os import os
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from app.utils.format import get_image_url
from .deps import get_current_user from .deps import get_current_user
from .jwt import create_jwt_token, create_refresh_token, verify_access_token, decode_token from .jwt import create_jwt_token, create_refresh_token, verify_access_token, decode_token
@@ -12,24 +11,24 @@ router = APIRouter(tags=["Authentication"])
# 挂载第三方 OAuth 的路由器 # 挂载第三方 OAuth 的路由器
router.include_router(oauth_router) router.include_router(oauth_router)
@router.get("/me") # 定义一个动态获取当前完整用户对象的依赖项
def me(user_id = Depends(get_current_user)): # 这样 auth 包不需要知道 User 模型长什么样
# 注意:根据上一轮修改,get_current_user 现在直接返回 user_id # 也不需要知道 SessionLocal 是什么
from app.db import SessionLocal # 全部由外部动态提供
from app.models import User def get_user_by_id(user_id: str = Depends(get_current_user)):
"""
这是一个占位/契约函数
我们在 main.py 中可以通过 app.dependency_overrides 或者直接在使用时
把具体的数据库查询逻辑绑定到这里
"""
raise NotImplementedError("Please override or inject the implementation of this function in the external `main.py` file.")
db = SessionLocal() @router.get("/me")
try: def me(current_user = Depends(get_user_by_id)):
user = db.query(User).filter(User.id == user_id).first() """
if not user: current_user 是由外部解析并传进来的完整用户对象或者是字典
raise HTTPException(status_code=404, detail="User not found") """
return { return current_user
"email": user.email,
"name": user.name,
"avatar": get_image_url(user.avatar),
}
finally:
db.close()
@router.post("/refresh") @router.post("/refresh")
def refresh_token(refresh_token: str): def refresh_token(refresh_token: str):
@@ -48,6 +47,7 @@ def logout():
__all__ = [ __all__ = [
"router", "router",
"get_current_user", "get_current_user",
"get_user_by_id",
"create_jwt_token", "create_jwt_token",
"create_refresh_token", "create_refresh_token",
"verify_access_token", "verify_access_token",
View File
+1 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import os import os
import jwt import auth.jwt as jwt
from fastapi import HTTPException, status from fastapi import HTTPException, status
JWT_SECRET = os.getenv("JWT_SECRET") JWT_SECRET = os.getenv("JWT_SECRET")
+197
View File
@@ -0,0 +1,197 @@
import httpx
from fastapi import APIRouter, Depends, Request, HTTPException, BackgroundTasks
from fastapi.responses import RedirectResponse
import os
import secrets
from auth.deps import get_current_user
from app.db import SessionLocal
from app.models import User
from auth.jwt import create_jwt_token, create_refresh_token, decode_token
from app.utils.format import get_image_url
router = APIRouter()
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
# 定义独立的后台任务函数,用于处理头像转存到 S3 的耗时操作
async def async_upload_avatar_task(google_avatar_url: str, user_id: str):
"""
后台任务函数:下载 Google 头像并上传到 S3,然后更新用户记录。
因为主接口的 db 已经关闭,这里需要独立管理 db 的生命周期。
"""
from app.utils.s3 import upload_google_avatar_to_s3
# 如果 Google 本身就没给头像,无需转存
if not google_avatar_url:
return
try:
# 执行耗时的网络 I/O:下载并上传到 S3
s3_avatar_url = await upload_google_avatar_to_s3(google_avatar_url, user_id)
# 成功后,开启一个独立的数据库连接写回数据
db_task = SessionLocal()
try:
user = db_task.query(User).filter(User.id == user_id).first()
if user:
user.avatar = s3_avatar_url
db_task.commit()
print(f"成功在后台为用户 {user_id} 转存头像到 S3")
finally:
db_task.close() # 必须手动关闭连接
except Exception as s3_err:
# 如果 S3 依然失败,打印错误日志,但此时绝不会打扰到已经登录的用户
print(f"Background S3 Upload failed for user {user_id}: {s3_err}")
@router.get("/login/google")
def login_google():
state = secrets.token_urlsafe(16)
url = (
f"{GOOGLE_AUTH_URL}"
f"?client_id={GOOGLE_CLIENT_ID}"
f"&redirect_uri={GOOGLE_REDIRECT_URI}"
f"&response_type=code"
f"&scope=openid email profile"
f"&state={state}"
)
response = RedirectResponse(url)
response.set_cookie(
"oauth_state",
state,
httponly=True,
secure=True,
samesite="lax"
)
return response
@router.get("/auth/callback")
async def auth_callback(request: Request, code: str, state: str, background_tasks: BackgroundTasks):
cookie_state = request.cookies.get("oauth_state")
if not cookie_state or cookie_state != state:
raise HTTPException(status_code=400, detail="Invalid state")
async with httpx.AsyncClient() as client:
token_res = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
)
token_json = token_res.json()
access_token = token_json["access_token"]
user_res = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
user_info = user_res.json()
db = SessionLocal()
try:
user = db.query(User).filter(User.email == user_info["email"]).first()
if not user:
# 提取邮箱前缀作为 handle 基础
# 比如 "john.doe@gmail.com" -> "john_doe"
base_handle = user_info["email"].split("@")[0].replace(".", "_").lower()
# 拼接一个随机短字符串,确保 handle 的唯一性
import secrets
random_suffix = secrets.token_hex(3) # 生成 6 位随机字符
suggested_handle = f"{base_handle}_{random_suffix}"
# 如果 Google 没有头像,使用 DiceBear 头像生成器作为兜底
google_avatar = user_info.get("picture")
if not google_avatar:
default_avatar_url = f"https://api.dicebear.com/7.x/lorelei/svg?seed={base_handle}"
else:
default_avatar_url = google_avatar
user = User(
google_id=user_info["id"],
email=user_info["email"],
name=user_info["name"],
avatar=default_avatar_url,
handle=suggested_handle,
)
db.add(user)
db.commit()
db.refresh(user)
# 异步/同步将头像转存到 S3
# FastAPI 会在把下方的 return 响应发送给浏览器之后,立刻在后台启动这个任务
if google_avatar:
background_tasks.add_task(
async_upload_avatar_task,
google_avatar,
str(user.id)
)
# 这里不需要再次 refresh,除非后面还要用到更新后的 user 对象
# db.refresh(user)
finally:
db.close()
jwt_token = create_jwt_token({
"sub": user.id,
"email": user.email,
})
refresh_token = create_refresh_token({
"sub": user.id,
"email": user.email,
})
return {
"access_token": jwt_token,
"refresh_token": refresh_token,
"token_type": "bearer",
# "user": {
# "email": user.email,
# "name": user.name,
# "avatar": get_image_url(user.avatar),
# }
}
@router.get("/me")
def me(user = Depends(get_current_user)):
return {
"email": user.email,
"name": user.name,
"avatar": get_image_url(user.avatar),
}
@router.post("/refresh")
def refresh_token(refresh_token: str):
payload = decode_token(refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=401)
user_id = payload.get("sub")
new_access = create_jwt_token({"sub": user_id})
return {"access_token": new_access}
@router.post("/logout")
def logout():
return {"message": "ok"}
@@ -3,10 +3,10 @@ from fastapi import APIRouter, Depends, Request, HTTPException, BackgroundTasks
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
import os import os
import secrets import secrets
from app.auth.deps import get_current_user from auth.deps import get_current_user
from app.db import SessionLocal from app.db import SessionLocal
from app.models import User from app.models import User
from app.auth.jwt import create_jwt_token, create_refresh_token, decode_token from auth.jwt import create_jwt_token, create_refresh_token, decode_token
from app.utils.format import get_image_url from app.utils.format import get_image_url
router = APIRouter() router = APIRouter()