Compare commits

...

9 Commits

Author SHA1 Message Date
Zayden-Jung 61e3d71b72 make a media api, encapsulate the original S3 bucket address
there are 3 parts, from back to head, url inside the bucket -> url of backend media api -> backend domain, only the last part is spliced by frontend, the other two parts are spliced with sqlalchemy
2026-05-20 17:31:49 +08:00
Zayden-Jung d2162f93e0 fix context; decoupling decode_token algo 2026-05-20 15:40:59 +08:00
Zayden-Jung b95cce592f fullfill graphql usertype; change db session to dep injection 2026-05-20 14:40:05 +08:00
Zayden-Jung f3b7fedade feat(graphql): implement secure session refresh mutation with token rotation 2026-05-20 03:10:07 +08:00
Zayden-Jung 9dfe000f2d refactor(auth): remove legacy RESTful user routes and migrate authentication to GraphQL context
- Rename get_current_user to get_current_user_id in auth/deps.py to clarify intent
- Remove legacy /me, /logout routes, and placeholder function from auth/__init__.py
- Remove dependency_overrides from app/main.py
- Update CustomContext to resolve and cache full User objects via db query
2026-05-20 02:52:54 +08:00
Zayden-Jung d54aeaa61a load dotenv everywhere it's needed; postgresql pool pre ping 2026-05-20 02:21:22 +08:00
Zayden-Jung c68d88b8c1 feat(auth): integrate Google OAuth2 and secure cookie management
- Configured CORS middleware to allow cross-origin requests from the frontend.
- Implemented Google OAuth2 login flow and post-auth frontend redirection.
- Managed secure HTTP-only cookies for access, refresh, and state tokens.
2026-05-19 23:55:03 +08:00
Zayden-Jung 2b5e193046 auth.py is a duplication of google.py; rename jwt.py -> tokens.py avoid confuse with pyjwt 2026-05-19 23:23:21 +08:00
Zayden-Jung a073fa5962 pyjwt not jwt 2026-05-19 22:56:04 +08:00
17 changed files with 364 additions and 348 deletions
+10 -4
View File
@@ -6,21 +6,27 @@ prompts:
preset:
auth:
- "app/auth.py"
- "app/auth/*"
- "auth/*"
s3:
- "app/models/base.py"
- "app/models/user.py"
- "app/utils/s3.py"
- "app/auth.py"
- "auth.py"
db_model:
- "app/models/*"
- "app/db.py"
# - "app/auth.py"
- "app/main.py"
graphql:
- "app/graphql/*"
- "app/main.py"
get_current_user:
- "app/main.py"
- "auth/__init__.py"
- "auth/deps.py"
- "auth/tokens.py"
- "auth/oauth/google.py"
- "app/graphql/*"
+18 -1
View File
@@ -18,5 +18,22 @@ elif DATABASE_URL and DATABASE_URL.startswith("postgres+"):
if not DATABASE_URL:
raise ValueError("DATABASE_URL environment variable is not set or empty.")
engine = create_engine(DATABASE_URL)
engine = create_engine(
DATABASE_URL,
pool_recycle=3600,
pool_pre_ping=True,
connect_args={
"keepalives": 1,
"keepalives_idle": 30,
"keepalives_interval": 10,
"keepalives_count": 5
}
)
SessionLocal = sessionmaker(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+30 -6
View File
@@ -1,9 +1,9 @@
from app.models.user import User
from auth.deps import get_current_user
from auth.deps import get_current_user_id
from fastapi import Request
from strawberry.fastapi import BaseContext
from strawberry.dataloader import DataLoader
from typing import List
from typing import List, Optional
from collections import defaultdict
from app.db import SessionLocal
from app.models import WorkspaceMember
@@ -28,21 +28,45 @@ async def load_members_by_workspace_ids(workspace_ids: List[str]) -> List[List[W
class CustomContext(BaseContext):
def __init__(self, request: Request):
super().__init__(request)
super().__init__()
self.db = SessionLocal()
self.request = request
self._current_user: Optional[User] = None
self.members_loader = DataLoader(load_fn=load_members_by_workspace_ids)
async def get_current_user(self) -> User | None:
# 复用现有的 auth 逻辑
"""在 Context 级别负责把 user_id 变成真正的数据库 User 对象"""
if self._current_user is not None:
return self._current_user
try:
return await get_current_user(self.request)
except Exception:
# 打印 Request Headers,确认 Token 是否真的传过来了
# print(f"DEBUG: Auth Headers: {self.request.headers.get('authorization')}")
# print(f"DEBUG: Cookies: {self.request.cookies}")
# 借用 auth 包的纯工具解出 ID
user_id = get_current_user_id(self.request)
# print(f"DEBUG: Extracted User ID: {user_id}") # 确认 ID 是否解出
if not user_id:
return None
# 在 Context 的数据库连接中查询完整用户
user = self.db.query(User).filter(User.id == user_id).first()
# print(f"DEBUG: DB User Found: {user}") # 确认数据库是否查到
self._current_user = user
return user
except Exception as e:
# 打印真实的异常信息
# print(f"DEBUG: Error in get_current_user: {str(e)}")
import traceback
traceback.print_exc() # 打印堆栈
return None
async def get_context(request: Request) -> AsyncGenerator[CustomContext, None]:
# print("--- NEW GRAPHQL REQUEST RECEIVED ---")
context = CustomContext(request)
try:
yield context
+95 -10
View File
@@ -1,18 +1,103 @@
import os
import strawberry
from auth.jwt import create_jwt_token, create_refresh_token
from strawberry.types import Info
from dotenv import load_dotenv
from urllib.parse import quote
import json
@strawberry.type
class AuthResponse:
access_token: str
refresh_token: str
token_type: str
from app.graphql.context import CustomContext
from auth.tokens import decode_token, create_jwt_token, create_refresh_token
from app.utils.format import get_image_url
load_dotenv()
ENV_MODE = os.getenv("ENV_MODE")
IS_PROD = ENV_MODE == "prod"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
ACCESS_TOKEN_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60
@strawberry.type
class Mutation:
@strawberry.mutation
def logout(self) -> str:
def logout(self, info: Info[CustomContext, None]) -> str:
"""退出登录:清除浏览器的 HttpOnly Cookies"""
response = info.context.response
# 让浏览器立刻过期这些 cookie
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
response.delete_cookie("user_info")
return "ok"
# 提示:像 Google OAuth 这种涉及第三方重定向的流程,
# 依然保留现有的 RESTful (/login/google 和 /auth/callback) 会更简单。
# 登录成功后重定向回前端,前端拿到 token,后续所有数据交互全部走 GraphQL。
@strawberry.mutation
async def refresh_session(self, info: Info[CustomContext, None]) -> str:
"""
刷新 Session:如果 access_token 过期了,前端可以调用这个接口用 refresh_token 换新的 access_token。
同时如果 refresh_token 已经过半寿命了,这个接口也会顺便轮转一个新的 refresh_token,确保用户体验的连续性。
"""
request = info.context.request
response = info.context.response
# 从 Cookie 获取并校验 refresh_token
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise Exception("No refresh token found in cookies")
try:
payload = decode_token(refresh_token)
except Exception:
raise Exception("Invalid or expired refresh token")
if payload.get("type") != "refresh":
raise Exception("Token type mismatch")
user = await info.context.get_current_user()
# 如果 access_token 已经彻底过期导致 Context 没捞到用户,
# 我们用 refresh_token 里的 sub 去 Context 的 db 里再查一遍用户
from app.models.user import User
if not user:
user_id = payload.get("sub")
user = info.context.db.query(User).filter(User.id == user_id).first()
if not user:
raise Exception("User no longer exists")
# 始终生成并写入新的短效 access_token
token_data = {"sub": str(user.id), "email": user.email}
new_access = create_jwt_token(token_data)
ACCESS_TOKEN_MAX_AGE = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) * 60
response.set_cookie(
key="access_token", value=new_access, httponly=True,
max_age=ACCESS_TOKEN_MAX_AGE, samesite="lax", secure=IS_PROD,
)
# 检查 refresh_token 是否已经消耗了超过一半的生命周期
import time
exp = payload.get("exp") # 过期绝对秒数
iat = payload.get("iat") # 签发绝对秒数
now = int(time.time())
# 如果当前时间已经过了总寿命的一半,就触发轮转,下发新的 refresh_token
if (now - iat) > (exp - iat) / 2:
new_refresh = create_refresh_token(token_data)
REFRESH_TOKEN_MAX_AGE = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7")) * 24 * 60 * 60
response.set_cookie(
key="refresh_token", value=new_refresh, httponly=True,
max_age=REFRESH_TOKEN_MAX_AGE, samesite="lax", secure=IS_PROD,
)
# 同步写入并续期前端所需的 user_info
response.set_cookie(
key="user_info",
value=quote(json.dumps({
"email": user.email,
"name": user.name,
"avatar": get_image_url(user.avatar) if user.avatar else None
})),
max_age=ACCESS_TOKEN_MAX_AGE,
httponly=False
)
return "ok"
+26 -4
View File
@@ -4,17 +4,39 @@ from datetime import datetime
from strawberry.types import Info
from app.utils.format import get_image_url
from app.graphql.context import CustomContext
from app.models.user import User
@strawberry.type
class UserType:
id: strawberry.ID
id: str
email: str
name: Optional[str]
name: str
handle: Optional[str]
avatar: Optional[str]
@strawberry.field
def avatar(self, root: User) -> Optional[str]:
return root.avatar_url
# 互动统计字段
following_count: int
follower_count: int
# 统计汇总字段
owner_project_count: int
owner_likes_count: int
owner_favorites_count: int
owner_shares_count: int
admin_project_count: int
admin_likes_count: int
admin_favorites_count: int
admin_shares_count: int
user_project_count: int
user_likes_count: int
user_favorites_count: int
user_shares_count: int
created_at: datetime
# 动态解析字段:例如自动处理头像的绝对路径
+21 -18
View File
@@ -1,8 +1,12 @@
import os
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from strawberry import Schema
from strawberry.fastapi import GraphQLRouter
from dotenv import load_dotenv
from auth import router as auth_router, get_user_by_id, get_current_user
from auth import router as auth_router
from app.media.router import router as media_router
from app.db import engine, SessionLocal
from app.models import Base, User
@@ -19,6 +23,20 @@ Base.metadata.create_all(bind=engine)
# 创建 FastAPI 实例
app = FastAPI()
# 获取环境变量字符串,并转成列表
load_dotenv()
origins_str = os.getenv("CORS_ORIGINS", "http://localhost:3000")
origins = [origin.strip() for origin in origins_str.split(",")]
# 配置 CORS 中间件,允许前端访问
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 组装 GraphQL Schema
schema = Schema(query=Query, mutation=Mutation)
@@ -27,25 +45,10 @@ graphql_router = GraphQLRouter(schema, context_getter=get_context)
# 挂载路由
app.include_router(auth_router) # 保留现有的 auth 路由供 Google OAuth 回调使用
app.include_router(media_router) # 挂载媒体路由,提供头像代理访问等等
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
# 打印路由测试
for route in app.routes:
methods = getattr(route, "methods", "N/A")
print(f"Path: {route.path} | Name: {route.name} | Methods: {methods}")
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
import oss2
from app.utils.s3 import bucket
router = APIRouter(prefix="/v1/media", tags=["Media"])
@router.get("/{file_path:path}")
async def get_avatar(file_path: str):
"""
通过流式传输代理阿里云 OSS 中的图片
file_path 接收 avatars/xxx/avatar.jpg 等任何相对路径
"""
s3_key = file_path.lstrip("/")
if not s3_key:
raise HTTPException(status_code=400, detail="Invalid file path")
try:
# 从阿里云 OSS 获取对象
# oss2 的 get_object 是同步阻塞的
# 初期可以直接用,后续高并发可以放入 run_in_executor
oss_object = bucket.get_object(s3_key)
# 获取文件的 Content-Type
content_type = oss_object.headers.get("Content-Type", "image/jpeg")
# 创建一个生成器来分块读取 OSS 数据,防止大文件时撑爆内存
def image_stream():
while True:
chunk = oss_object.read(1024 * 64) # 每次读 64KB
if not chunk:
break
yield chunk
# 流式返回给前端
return StreamingResponse(image_stream(), media_type=content_type)
except oss2.exceptions.NoSuchKey:
# 阿里云 OSS 专属的 404 异常
raise HTTPException(status_code=404, detail="Image not found in storage")
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Internal storage service error")
+13
View File
@@ -17,6 +17,19 @@ class User(Base, AutoID):
handle = Column(String, unique=True, index=True, nullable=True)
avatar = Column(String)
@property
def avatar_url(self) -> str | None:
if not self.avatar:
return None
# 如果是第三方(如未迁移成功时降级的 Google 原始链接),直接返回
if self.avatar.startswith(("http://", "https://")):
return self.avatar
path = self.avatar.lstrip("/")
return f"/v1/media/{path}"
# ---- 互动统计字段 ----
following_count = Column(Integer, default=0, nullable=False, server_default="0") # 关注了多少人
follower_count = Column(Integer, default=0, nullable=False, server_default="0") # 粉丝数
+3
View File
@@ -1,4 +1,7 @@
import os
from dotenv import load_dotenv
load_dotenv()
OSS_BASE_URL = os.getenv("OSS_BASE_URL", "").rstrip("/")
+3 -2
View File
@@ -1,8 +1,9 @@
import oss2
import httpx
import os
import io
from fastapi import HTTPException
from dotenv import load_dotenv
load_dotenv()
# 加载配置
ACCESS_KEY_ID = os.getenv("S3_ACCESS_KEY_ID")
+5 -40
View File
@@ -1,53 +1,18 @@
import os
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter
from .deps import get_current_user
from .jwt import create_jwt_token, create_refresh_token, verify_access_token, decode_token
from .deps import get_current_user_id
from .tokens import create_jwt_token, create_refresh_token, verify_access_token
from .oauth import oauth_router
# 创建全局 auth 路由器
# 创建全局 auth 路由器并挂载第三方 OAuth 的路由器
router = APIRouter(tags=["Authentication"])
# 挂载第三方 OAuth 的路由器
router.include_router(oauth_router)
# 定义一个动态获取当前完整用户对象的依赖项
# 这样 auth 包不需要知道 User 模型长什么样
# 也不需要知道 SessionLocal 是什么
# 全部由外部动态提供
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.")
@router.get("/me")
def me(current_user = Depends(get_user_by_id)):
"""
current_user 是由外部解析并传进来的完整用户对象(或者是字典)
"""
return current_user
@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"}
# 显式声明暴露的接口
__all__ = [
"router",
"get_current_user",
"get_user_by_id",
"get_current_user_id",
"create_jwt_token",
"create_refresh_token",
"verify_access_token",
+8 -4
View File
@@ -1,19 +1,23 @@
from fastapi import Request, HTTPException
import os
from .jwt import decode_token
from dotenv import load_dotenv
from .tokens import decode_token
load_dotenv()
JWT_SECRET = os.getenv("JWT_SECRET")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
def get_current_user(request: Request):
def get_current_user_id(request: Request):
"""仅从 Cookie 中提取并解密出用户的 user_id (sub)"""
token = request.cookies.get("access_token")
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
payload = decode_token(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
payload = decode_token(token)
return payload["sub"]
except:
raise HTTPException(status_code=401, detail="Invalid token")
-197
View File
@@ -1,197 +0,0 @@
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"}
+59 -39
View File
@@ -1,16 +1,24 @@
from ast import Is
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
import json
from urllib.parse import quote
from dotenv import load_dotenv
from sqlalchemy.orm import Session
from app.db import SessionLocal, get_db
from app.models import User
from auth.jwt import create_jwt_token, create_refresh_token, decode_token
from auth.tokens import create_jwt_token, create_refresh_token, decode_token
from app.utils.format import get_image_url
router = APIRouter()
load_dotenv()
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
@@ -19,6 +27,18 @@ 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"
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
ENV_MODE = os.getenv("ENV_MODE")
IS_PROD = ENV_MODE == "prod"
# 在文件顶部获取环境变量
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# 统一转换为秒数(max_age 需要秒)
ACCESS_TOKEN_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60
REFRESH_TOKEN_MAX_AGE = REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
# 定义独立的后台任务函数,用于处理头像转存到 S3 的耗时操作
async def async_upload_avatar_task(google_avatar_url: str, user_id: str):
"""
@@ -75,7 +95,13 @@ def login_google():
return response
@router.get("/auth/callback")
async def auth_callback(request: Request, code: str, state: str, background_tasks: BackgroundTasks):
async def auth_callback(
request: Request,
code: str,
state: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
cookie_state = request.cookies.get("oauth_state")
if not cookie_state or cookie_state != state:
@@ -103,9 +129,6 @@ async def auth_callback(request: Request, code: str, state: str, background_task
user_info = user_res.json()
db = SessionLocal()
try:
user = db.query(User).filter(User.email == user_info["email"]).first()
if not user:
@@ -147,8 +170,6 @@ async def auth_callback(request: Request, code: str, state: str, background_task
# 这里不需要再次 refresh,除非后面还要用到更新后的 user 对象
# db.refresh(user)
finally:
db.close()
jwt_token = create_jwt_token({
"sub": user.id,
@@ -160,38 +181,37 @@ async def auth_callback(request: Request, code: str, state: str, background_task
"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),
# }
}
response = RedirectResponse(url=f"{FRONTEND_URL}")
# @router.get("/me")
# def me(user = Depends(get_current_user)):
# return {
# "email": user.email,
# "name": user.name,
# "avatar": get_image_url(user.avatar),
# }
response.set_cookie(
key="access_token",
value=jwt_token,
httponly=True,
max_age=ACCESS_TOKEN_MAX_AGE,
samesite="lax",
secure=IS_PROD,
)
# @router.post("/refresh")
# def refresh_token(refresh_token: str):
# payload = decode_token(refresh_token)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
max_age=REFRESH_TOKEN_MAX_AGE,
samesite="lax",
secure=IS_PROD,
)
# if payload.get("type") != "refresh":
# raise HTTPException(status_code=401)
response.set_cookie(
key="user_info",
value=quote(json.dumps({
"email": user.email,
"name": user.name,
"avatar": user.avatar
})),
max_age=ACCESS_TOKEN_MAX_AGE,
httponly=False
)
# user_id = payload.get("sub")
response.delete_cookie("oauth_state")
# new_access = create_jwt_token({"sub": user_id})
# return {"access_token": new_access}
# @router.post("/logout")
# def logout():
# return {"message": "ok"}
return response
+4 -1
View File
@@ -1,7 +1,10 @@
from datetime import datetime, timedelta, timezone
import os
import auth.jwt as jwt
import jwt
from fastapi import HTTPException, status
from dotenv import load_dotenv
load_dotenv()
JWT_SECRET = os.getenv("JWT_SECRET")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+1 -1
View File
@@ -10,9 +10,9 @@ dependencies = [
"dotenv>=0.9.9",
"fastapi>=0.136.1",
"httpx>=0.28.1",
"jwt>=1.4.0",
"oss2>=2.19.1",
"psycopg2-binary>=2.9.12",
"pyjwt[crypto]>=2.12.1",
"python-jose>=3.5.0",
"sqlalchemy>=2.0.49",
"strawberry-graphql[fastapi]>=0.315.5",
Generated
+16 -14
View File
@@ -148,9 +148,9 @@ dependencies = [
{ name = "dotenv" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jwt" },
{ name = "oss2" },
{ name = "psycopg2-binary" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-jose" },
{ name = "sqlalchemy" },
{ name = "strawberry-graphql", extra = ["fastapi"] },
@@ -165,9 +165,9 @@ requires-dist = [
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "fastapi", specifier = ">=0.136.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jwt", specifier = ">=1.4.0" },
{ name = "oss2", specifier = ">=2.19.1" },
{ name = "psycopg2-binary", specifier = ">=2.9.12" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" },
{ name = "python-jose", specifier = ">=3.5.0" },
{ name = "sqlalchemy", specifier = ">=2.0.49" },
{ name = "strawberry-graphql", extras = ["fastapi"], specifier = ">=0.315.5" },
@@ -434,18 +434,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/cb/5f001272b6faeb23c1c9e0acc04d48eaaf5c862c17709d20e3469c6e0139/jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f", size = 24489, upload-time = "2020-05-12T22:03:45.643Z" },
]
[[package]]
name = "jwt"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/20/21254c9e601e6c29445d1e8854c2a81bdb554e07a82fb1f9846137a6965c/jwt-1.4.0.tar.gz", hash = "sha256:f6f789128ac247142c79ee10f3dba6e366ec4e77c9920d18c1592e28aa0a7952", size = 24911, upload-time = "2025-06-23T13:28:38.289Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/80/34e3fae850adb0b7b8b9b1cf02b2d975fcb68e0e8eb7d56d6b4fc23f7433/jwt-1.4.0-py3-none-any.whl", hash = "sha256:7560a7f1de4f90de94ac645ee0303ac60c95b9e08e058fb69f6c330f71d71b11", size = 18248, upload-time = "2025-06-23T13:28:37.012Z" },
]
[[package]]
name = "mako"
version = "1.3.12"
@@ -623,6 +611,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"