Skip to content

Commit 6b9b411

Browse files
authored
Merge pull request #5 from luojiyin1987/feat/supabase-auth
feat: 添加 Supabase JWT 认证
2 parents 19ed168 + c4f2a90 commit 6b9b411

18 files changed

Lines changed: 1631 additions & 401 deletions

File tree

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,43 @@ ASR_API_BASE=https://api.openai.com/v1
363363
ASR_MODEL=whisper-1
364364
```
365365

366+
Supabase 登录需要配置后端 JWT 和前端环境变量:
367+
368+
```env
369+
# backend/.env
370+
SUPABASE_JWT_SECRET=your-jwt-secret-here
371+
SUPABASE_URL=https://xxx.supabase.co
372+
373+
# web/.env.local
374+
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
375+
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
376+
```
377+
378+
### `.env` / `.env.local` 配置流程
379+
380+
1. 在 Supabase 控制台获取参数:
381+
- Project Settings → API → Project URL → 填到 `SUPABASE_URL``NEXT_PUBLIC_SUPABASE_URL`
382+
- Project Settings → API → API Keys → **anon/public** key → 填到 `NEXT_PUBLIC_SUPABASE_ANON_KEY`
383+
- Project Settings → JWT Keys → **Legacy JWT Secret** → 填到 `SUPABASE_JWT_SECRET`
384+
385+
2.`backend/.env` 写入(示例):
386+
```env
387+
SUPABASE_JWT_SECRET=your-jwt-secret-here
388+
SUPABASE_URL=https://xxx.supabase.co
389+
```
390+
391+
3.`web/.env.local` 写入(示例):
392+
```env
393+
# 后端 API 地址
394+
NEXT_PUBLIC_API_URL=http://localhost:8000
395+
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
396+
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
397+
```
398+
399+
4. 重启前后端进程使环境变量生效。
400+
401+
> 注意:`SUPABASE_JWT_SECRET` 仅用于后端验证 JWT。当前后端使用 HS256(Legacy JWT Secret);如果项目已切换到新的 JWT Signing Keys(P-256),需要先改后端验签方式。
402+
366403
### 测试
367404

368405
```bash

backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies = [
2424
"python-dotenv>=1.0.0",
2525
"yt-dlp>=2024.0.0",
2626
"youtube-transcript-api>=0.6.0",
27+
"python-jose[cryptography]>=3.3.0",
2728
]
2829

2930
[dependency-groups]

backend/src/vmarker/api/auth.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""
2+
[INPUT]: 依赖 jose, os, FastAPI
3+
[OUTPUT]: get_current_user 依赖函数, AuthUser 数据模型
4+
[POS]: Supabase JWT 验证
5+
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
6+
"""
7+
8+
import os
9+
from typing import Annotated
10+
11+
from fastapi import Depends, HTTPException, status
12+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
13+
from jose import JWTError, jwt
14+
from pydantic import BaseModel
15+
16+
# =============================================================================
17+
# 配置
18+
# =============================================================================
19+
20+
SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", "")
21+
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
22+
23+
ALGORITHM = "HS256"
24+
25+
26+
# =============================================================================
27+
# 数据模型
28+
# =============================================================================
29+
30+
31+
class AuthUser(BaseModel):
32+
"""认证用户信息"""
33+
id: str
34+
email: str | None = None
35+
role: str = "authenticated"
36+
aud: str = "authenticated"
37+
38+
39+
# =============================================================================
40+
# JWT 验证
41+
# =============================================================================
42+
43+
security = HTTPBearer(auto_error=False)
44+
45+
46+
async def get_current_user(
47+
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
48+
) -> AuthUser:
49+
"""
50+
验证 Supabase JWT 并返回当前用户
51+
52+
Args:
53+
credentials: HTTP Bearer Token
54+
55+
Returns:
56+
AuthUser: 当前用户信息
57+
58+
Raises:
59+
HTTPException 401: Token 无效或缺失
60+
"""
61+
if not credentials:
62+
raise HTTPException(
63+
status_code=status.HTTP_401_UNAUTHORIZED,
64+
detail="Missing authorization header",
65+
)
66+
67+
token = credentials.credentials
68+
69+
try:
70+
payload = jwt.decode(
71+
token,
72+
SUPABASE_JWT_SECRET,
73+
algorithms=[ALGORITHM],
74+
options={"verify_aud": False},
75+
)
76+
except JWTError as e:
77+
raise HTTPException(
78+
status_code=status.HTTP_401_UNAUTHORIZED,
79+
detail=f"Invalid token: {str(e)}",
80+
) from e
81+
82+
user_id = payload.get("sub")
83+
if not user_id:
84+
raise HTTPException(
85+
status_code=status.HTTP_401_UNAUTHORIZED,
86+
detail="Token missing user id",
87+
)
88+
89+
return AuthUser(
90+
id=user_id,
91+
email=payload.get("email"),
92+
role=payload.get("role", "authenticated"),
93+
aud=payload.get("aud", "authenticated"),
94+
)
95+
96+
97+
async def get_optional_user(
98+
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
99+
) -> AuthUser | None:
100+
"""
101+
可选的用户认证,不抛出异常
102+
103+
Returns:
104+
AuthUser | None: 当前用户信息,未认证返回 None
105+
"""
106+
if not credentials:
107+
return None
108+
109+
try:
110+
return await get_current_user(credentials)
111+
except HTTPException:
112+
return None
113+
114+
115+
# =============================================================================
116+
# 类型别名
117+
# =============================================================================
118+
119+
CurrentUser = Annotated[AuthUser, Depends(get_current_user)]
120+
OptionalUser = Annotated[AuthUser | None, Depends(get_optional_user)]

backend/src/vmarker/api/main.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,6 @@
1313
from fastapi.middleware.cors import CORSMiddleware
1414
from pydantic import BaseModel
1515

16-
from vmarker import __version__
17-
from vmarker.api.routes import chapter_bar, progress_bar, shownotes, subtitle, video, youtube
18-
1916

2017
# =============================================================================
2118
# 加载环境变量
@@ -25,6 +22,9 @@
2522
_env_path = Path(__file__).parent.parent.parent.parent / ".env"
2623
load_dotenv(_env_path)
2724

25+
from vmarker import __version__
26+
from vmarker.api.routes import auth, chapter_bar, progress_bar, shownotes, subtitle, video, youtube
27+
2828

2929
# =============================================================================
3030
# 生命周期
@@ -84,6 +84,7 @@ async def health():
8484
# 注册功能路由
8585
# =============================================================================
8686

87+
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Auth"])
8788
app.include_router(chapter_bar.router, prefix="/api/v1/chapter-bar", tags=["Chapter Bar"])
8889
app.include_router(shownotes.router, prefix="/api/v1/shownotes", tags=["Show Notes"])
8990
app.include_router(subtitle.router, prefix="/api/v1/subtitle", tags=["Subtitle"])
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""
2+
[INPUT]: 依赖 FastAPI, auth 模块
3+
[OUTPUT]: auth 路由 (router)
4+
[POS]: 认证相关 API 端点
5+
[PROTOCOL]: 变更时更新此头部, then check CLAUDE.md
6+
"""
7+
8+
from fastapi import APIRouter
9+
10+
from vmarker.api.auth import AuthUser, CurrentUser, OptionalUser
11+
12+
router = APIRouter()
13+
14+
15+
# =============================================================================
16+
# 数据模型
17+
# =============================================================================
18+
19+
20+
class MeResponse(AuthUser):
21+
"""当前用户信息响应"""
22+
pass
23+
24+
25+
# =============================================================================
26+
# 认证端点
27+
# =============================================================================
28+
29+
30+
@router.get("/me", response_model=MeResponse)
31+
async def get_me(user: CurrentUser) -> AuthUser:
32+
"""
33+
获取当前登录用户信息
34+
35+
需要提供有效的 Supabase JWT Token:
36+
```
37+
Authorization: Bearer <access_token>
38+
```
39+
40+
Returns:
41+
AuthUser: 当前用户信息
42+
"""
43+
return user
44+
45+
46+
@router.get("/check")
47+
async def auth_check(user: OptionalUser) -> dict:
48+
"""
49+
检查认证状态(不强制登录)
50+
51+
- 已登录:返回用户信息
52+
- 未登录:返回 guest 状态
53+
54+
Returns:
55+
dict: 认证状态
56+
"""
57+
if user is None:
58+
return {"authenticated": False, "user": None}
59+
60+
return {
61+
"authenticated": True,
62+
"user": user.model_dump(),
63+
}

0 commit comments

Comments
 (0)