Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,43 @@ ASR_API_BASE=https://api.openai.com/v1
ASR_MODEL=whisper-1
```

Supabase 登录需要配置后端 JWT 和前端环境变量:

```env
# backend/.env
SUPABASE_JWT_SECRET=your-jwt-secret-here
SUPABASE_URL=https://xxx.supabase.co

# web/.env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```

### `.env` / `.env.local` 配置流程

1. 在 Supabase 控制台获取参数:
- Project Settings → API → Project URL → 填到 `SUPABASE_URL` 和 `NEXT_PUBLIC_SUPABASE_URL`
- Project Settings → API → API Keys → **anon/public** key → 填到 `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- Project Settings → JWT Keys → **Legacy JWT Secret** → 填到 `SUPABASE_JWT_SECRET`

2. 在 `backend/.env` 写入(示例):
```env
SUPABASE_JWT_SECRET=your-jwt-secret-here
SUPABASE_URL=https://xxx.supabase.co
```

3. 在 `web/.env.local` 写入(示例):
```env
# 后端 API 地址
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```

4. 重启前后端进程使环境变量生效。

> 注意:`SUPABASE_JWT_SECRET` 仅用于后端验证 JWT。当前后端使用 HS256(Legacy JWT Secret);如果项目已切换到新的 JWT Signing Keys(P-256),需要先改后端验签方式。

### 测试

```bash
Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies = [
"python-dotenv>=1.0.0",
"yt-dlp>=2024.0.0",
"youtube-transcript-api>=0.6.0",
"python-jose[cryptography]>=3.3.0",
]

[dependency-groups]
Expand Down
120 changes: 120 additions & 0 deletions backend/src/vmarker/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""
[INPUT]: 依赖 jose, os, FastAPI
[OUTPUT]: get_current_user 依赖函数, AuthUser 数据模型
[POS]: Supabase JWT 验证
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
"""

import os
from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from pydantic import BaseModel

# =============================================================================
# 配置
# =============================================================================

SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", "")
SUPABASE_URL = os.getenv("SUPABASE_URL", "")

ALGORITHM = "HS256"


# =============================================================================
# 数据模型
# =============================================================================


class AuthUser(BaseModel):
"""认证用户信息"""
id: str
email: str | None = None
role: str = "authenticated"
aud: str = "authenticated"


# =============================================================================
# JWT 验证
# =============================================================================

security = HTTPBearer(auto_error=False)


async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
) -> AuthUser:
"""
验证 Supabase JWT 并返回当前用户

Args:
credentials: HTTP Bearer Token

Returns:
AuthUser: 当前用户信息

Raises:
HTTPException 401: Token 无效或缺失
"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authorization header",
)

token = credentials.credentials

try:
payload = jwt.decode(
token,
SUPABASE_JWT_SECRET,
algorithms=[ALGORITHM],
options={"verify_aud": False},
)
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {str(e)}",
) from e

user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing user id",
)

return AuthUser(
id=user_id,
email=payload.get("email"),
role=payload.get("role", "authenticated"),
aud=payload.get("aud", "authenticated"),
)


async def get_optional_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
) -> AuthUser | None:
"""
可选的用户认证,不抛出异常

Returns:
AuthUser | None: 当前用户信息,未认证返回 None
"""
if not credentials:
return None

try:
return await get_current_user(credentials)
except HTTPException:
return None


# =============================================================================
# 类型别名
# =============================================================================

CurrentUser = Annotated[AuthUser, Depends(get_current_user)]
OptionalUser = Annotated[AuthUser | None, Depends(get_optional_user)]
7 changes: 4 additions & 3 deletions backend/src/vmarker/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from vmarker import __version__
from vmarker.api.routes import chapter_bar, progress_bar, shownotes, subtitle, video, youtube


# =============================================================================
# 加载环境变量
Expand All @@ -25,6 +22,9 @@
_env_path = Path(__file__).parent.parent.parent.parent / ".env"
load_dotenv(_env_path)

from vmarker import __version__
from vmarker.api.routes import auth, chapter_bar, progress_bar, shownotes, subtitle, video, youtube


# =============================================================================
# 生命周期
Expand Down Expand Up @@ -84,6 +84,7 @@ async def health():
# 注册功能路由
# =============================================================================

app.include_router(auth.router, prefix="/api/v1/auth", tags=["Auth"])
app.include_router(chapter_bar.router, prefix="/api/v1/chapter-bar", tags=["Chapter Bar"])
app.include_router(shownotes.router, prefix="/api/v1/shownotes", tags=["Show Notes"])
app.include_router(subtitle.router, prefix="/api/v1/subtitle", tags=["Subtitle"])
Expand Down
63 changes: 63 additions & 0 deletions backend/src/vmarker/api/routes/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
[INPUT]: 依赖 FastAPI, auth 模块
[OUTPUT]: auth 路由 (router)
[POS]: 认证相关 API 端点
[PROTOCOL]: 变更时更新此头部, then check CLAUDE.md
"""

from fastapi import APIRouter

from vmarker.api.auth import AuthUser, CurrentUser, OptionalUser

router = APIRouter()


# =============================================================================
# 数据模型
# =============================================================================


class MeResponse(AuthUser):
"""当前用户信息响应"""
pass


# =============================================================================
# 认证端点
# =============================================================================


@router.get("/me", response_model=MeResponse)
async def get_me(user: CurrentUser) -> AuthUser:
"""
获取当前登录用户信息

需要提供有效的 Supabase JWT Token:
```
Authorization: Bearer <access_token>
```

Returns:
AuthUser: 当前用户信息
"""
return user


@router.get("/check")
async def auth_check(user: OptionalUser) -> dict:
"""
检查认证状态(不强制登录)

- 已登录:返回用户信息
- 未登录:返回 guest 状态

Returns:
dict: 认证状态
"""
if user is None:
return {"authenticated": False, "user": None}

return {
"authenticated": True,
"user": user.model_dump(),
}
Loading