Files
character-roleplay-backend/app/graphql/mutations.py
T

103 lines
4.0 KiB
Python

import os
import strawberry
from strawberry.types import Info
from dotenv import load_dotenv
from urllib.parse import quote
import json
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, 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"
@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"