Skip to content

Commit 6fbfe3f

Browse files
ImTotemclaude
andcommitted
feat(api): remove Firebase, switch to Resend, add cookie auth and tracks
- Remove firebase-admin dependency and phone verification - Replace SMTP (aiosmtplib) with Resend API for email - Add EmailSender Protocol for extensible email provider - Add HTML email template for verification codes (10min TTL) - Add cookie-based auth (Set-Cookie on login/register) - Add GET /v1/auth/me and POST /v1/auth/logout endpoints - Add GET /v1/tracks with Google Sheets backend and seed defaults - Add CORS middleware with configurable origins - Auto-initialize Google Sheets schema on app startup - Add name field to RegisterRequest, remove firebase_token Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1b5f058 commit 6fbfe3f

19 files changed

Lines changed: 276 additions & 80 deletions

.claude/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"enabledPlugins": {
3+
"frontend-design@claude-plugins-official": true
4+
}
5+
}

.env.example

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@ JWT_EXPIRE_MINUTES=1440
1010
GOOGLE_SHEETS_ID=your-google-sheets-id
1111
GOOGLE_SERVICE_ACCOUNT_FILE=credentials.json
1212

13-
# SMTP (학교 이메일 인증)
14-
SMTP_HOST=smtp.gmail.com
15-
SMTP_PORT=587
16-
SMTP_USER=your-email@gmail.com
17-
SMTP_PASSWORD=your-app-password
13+
# Resend (학교 이메일 인증)
14+
RESEND_API_KEY=re_your_api_key
15+
RESEND_SENDER=onboarding@resend.dev
1816

19-
# Firebase
20-
FIREBASE_CREDENTIALS_FILE=firebase-credentials.json
17+
# CORS (쉼표로 여러 origin 구분)
18+
CORS_ORIGINS=http://localhost:3000

CLAUDE.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ Club management automation system using **n8n** to orchestrate **Google Workspac
77
```
88
Client → FastAPI (auth, member API) → Google Sheets (database)
99
→ Google OAuth (ID Token 검증)
10-
→ Firebase Auth (전화번호 검증)
11-
→ SMTP (학교 이메일 인증)
10+
→ Resend (학교 이메일 인증)
1211
1312
n8n (automation workflows) → Google Sheets (database) → Slack/Email (output)
1413
```

docs/member-api.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@
22

33
## 작업 목적
44
BCSDLab 동아리 회원 관리를 위한 FastAPI 백엔드 구현.
5-
Google OAuth 인증, 학교 이메일/전화번호 인증을 통한 회원가입과 JWT 기반 인증된 회원 조회 API를 제공한다.
5+
Google OAuth 인증, 학교 이메일 인증을 통한 회원가입과 JWT 기반 인증된 회원 조회 API를 제공한다.
66

77
## 아키텍처
88

99
```
1010
Client → FastAPI → Google Sheets (members 탭)
1111
→ Google OAuth (ID Token 검증)
12-
→ Firebase Auth (전화번호 검증)
13-
→ SMTP (학교 이메일 인증 코드)
12+
→ Resend (학교 이메일 인증 코드)
1413
```
1514

1615
- **데이터 저장**: Google Sheets `members` 탭 (PostgreSQL 없음)
1716
- **인증**: Google OAuth → JWT 발급
18-
- **회원가입**: Google OAuth + 학교 이메일 인증 + Firebase Phone Auth
17+
- **회원가입**: Google OAuth + 학교 이메일 인증
1918

2019
## 프로젝트 구조
2120

@@ -57,9 +56,8 @@ src/bcsd_api/
5756
- `JWT_SECRET`: JWT 서명 키
5857
- `GOOGLE_SHEETS_ID`: Google Sheets 문서 ID
5958
- `GOOGLE_SERVICE_ACCOUNT_FILE`: 서비스 계정 JSON 파일 경로
60-
- `SMTP_*`: 이메일 발송 설정
61-
- `FIREBASE_CREDENTIALS_FILE`: Firebase 서비스 계정 JSON
62-
59+
- `RESEND_API_KEY`: Resend API 키
60+
- `RESEND_SENDER`: 발신자 이메일 주소
6361
### Google Sheets 스키마 (members 탭)
6462
헤더 행: `id | name | email | school_email | phone | status | track | team | join_date | payment_status | last_updated`
6563

@@ -89,12 +87,10 @@ Spring의 `@ControllerAdvice` 패턴과 동일한 구조.
8987
## 알려진 제약사항
9088
- 이메일 인증 코드는 서버 재시작 시 소실 (in-memory)
9189
- Google Sheets API 속도 제한 (분당 60회) → 대량 트래픽 시 캐싱 필요
92-
- Firebase Admin SDK 초기화가 아직 `main.py`에 포함되지 않음 → 회원가입 시 Firebase 설정 필요
9390
- `confirm-email` 성공 여부를 `register` 시점에 서버 측에서 재검증하지 않음 → 클라이언트 플로우에 의존
9491

9592
## 향후 개선 사항
9693
- Redis 기반 인증 코드 저장소
97-
- Firebase Admin SDK 초기화 (`firebase_admin.initialize_app`)
9894
- 회원가입 시 이메일 인증 완료 여부를 서버에서 재확인하는 상태 관리
9995
- 회원 정보 수정 API (PUT /v1/members/{id})
10096
- 비밀번호 없는 인증이므로 refresh token 전략 검토

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ dependencies = [
1414
"google-auth>=2.38.0",
1515
"google-api-python-client>=2.165.0",
1616
"python-jose[cryptography]>=3.3.0",
17-
"firebase-admin>=6.6.0",
18-
"aiosmtplib>=3.0.0",
17+
18+
"resend>=2.0.0",
1919
]
2020

2121
[project.optional-dependencies]

src/bcsd_api/auth/router.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
from fastapi import APIRouter, Depends
1+
from fastapi import APIRouter, Depends, Response
22

33
from bcsd_api.config import Settings
4-
from bcsd_api.dependencies import get_settings, get_sheets
4+
from bcsd_api.dependencies import (
5+
current_user, get_email_sender, get_settings, get_sheets,
6+
)
7+
from bcsd_api.email.sender import EmailSender
58
from bcsd_api.sheets.client import SheetsClient
69

710
from . import service
@@ -10,6 +13,7 @@
1013
ConfirmEmailResponse,
1114
LoginRequest,
1215
LoginResponse,
16+
MeResponse,
1317
MessageResponse,
1418
RegisterRequest,
1519
VerifyEmailRequest,
@@ -18,22 +22,36 @@
1822
router = APIRouter(prefix="/v1/auth", tags=["auth"])
1923

2024

25+
def _set_cookie(response: Response, token: str, settings: Settings) -> None:
26+
response.set_cookie(
27+
key=settings.cookie_name,
28+
value=token,
29+
httponly=True,
30+
samesite="lax",
31+
secure=settings.cookie_secure,
32+
path="/",
33+
max_age=settings.jwt_expire_minutes * 60,
34+
)
35+
36+
2137
@router.post("/login", response_model=LoginResponse)
2238
def post_login(
2339
body: LoginRequest,
40+
response: Response,
2441
settings: Settings = Depends(get_settings),
2542
sheets: SheetsClient = Depends(get_sheets),
2643
) -> LoginResponse:
2744
token = service.login(body.google_token, settings, sheets)
45+
_set_cookie(response, token, settings)
2846
return LoginResponse(access_token=token)
2947

3048

3149
@router.post("/verify-email", response_model=MessageResponse)
32-
async def post_verify(
50+
def post_verify(
3351
body: VerifyEmailRequest,
34-
settings: Settings = Depends(get_settings),
52+
sender: EmailSender = Depends(get_email_sender),
3553
) -> MessageResponse:
36-
await service.send_verify(body.email, settings)
54+
service.send_verify(body.email, sender)
3755
return MessageResponse(message="verification code sent")
3856

3957

@@ -46,11 +64,33 @@ def post_confirm(body: ConfirmEmailRequest) -> ConfirmEmailResponse:
4664
@router.post("/register", response_model=LoginResponse)
4765
def post_register(
4866
body: RegisterRequest,
67+
response: Response,
4968
settings: Settings = Depends(get_settings),
5069
sheets: SheetsClient = Depends(get_sheets),
5170
) -> LoginResponse:
5271
token = service.register(
53-
body.google_token, body.school_email, body.phone,
54-
body.firebase_token, body.track, settings, sheets,
72+
body.google_token, body.name, body.school_email,
73+
body.phone, body.track, settings, sheets,
5574
)
75+
_set_cookie(response, token, settings)
5676
return LoginResponse(access_token=token)
77+
78+
79+
@router.get("/me", response_model=MeResponse)
80+
def get_me(user: dict = Depends(current_user)) -> MeResponse:
81+
return MeResponse(id=user["sub"], email=user["email"])
82+
83+
84+
@router.post("/logout", response_model=MessageResponse)
85+
def post_logout(
86+
response: Response,
87+
settings: Settings = Depends(get_settings),
88+
) -> MessageResponse:
89+
response.delete_cookie(
90+
key=settings.cookie_name,
91+
httponly=True,
92+
samesite="lax",
93+
secure=settings.cookie_secure,
94+
path="/",
95+
)
96+
return MessageResponse(message="logged out")

src/bcsd_api/auth/schema.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,16 @@ class ConfirmEmailResponse(BaseModel):
2525

2626
class RegisterRequest(BaseModel):
2727
google_token: str
28+
name: str
2829
school_email: str
2930
phone: str
30-
firebase_token: str
3131
track: str
3232

3333

34+
class MeResponse(BaseModel):
35+
id: str
36+
email: str
37+
38+
3439
class MessageResponse(BaseModel):
3540
message: str

src/bcsd_api/auth/service.py

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
from datetime import datetime, timezone, timedelta
22

3-
import firebase_admin.auth as firebase_auth
4-
53
from bcsd_api.config import Settings
6-
from bcsd_api.exception import BadRequest, Conflict, Unauthorized
4+
from bcsd_api.email.sender import EmailSender
5+
from bcsd_api.exception import Conflict, Unauthorized
76
from bcsd_api.id_gen import generate_id
87
from bcsd_api.sheets.client import SheetsClient
98

@@ -23,11 +22,8 @@ def login(google_token: str, settings: Settings, sheets: SheetsClient) -> str:
2322
return _issue_jwt(payload, settings)
2423

2524

26-
async def send_verify(email: str, settings: Settings) -> None:
27-
await verify.send_code(
28-
email, settings.smtp_host, settings.smtp_port,
29-
settings.smtp_user, settings.smtp_password,
30-
)
25+
def send_verify(email: str, sender: EmailSender) -> None:
26+
verify.send_code(email, sender)
3127

3228

3329
def confirm_verify(email: str, code: str) -> bool:
@@ -36,18 +32,17 @@ def confirm_verify(email: str, code: str) -> bool:
3632

3733
def register(
3834
google_token: str,
35+
name: str,
3936
school_email: str,
4037
phone: str,
41-
firebase_token: str,
4238
track: str,
4339
settings: Settings,
4440
sheets: SheetsClient,
4541
) -> str:
4642
profile = google_auth.verify_token(google_token, settings.google_client_id)
4743
_check_duplicate(profile["email"], sheets)
48-
_verify_firebase(firebase_token)
4944
member_id = generate_id("M")
50-
row = _build_row(member_id, profile, school_email, phone, track)
45+
row = _build_row(member_id, name, profile["email"], school_email, phone, track)
5146
sheets.append_row("members", row)
5247
payload = {"sub": member_id, "email": profile["email"]}
5348
return _issue_jwt(payload, settings)
@@ -65,22 +60,15 @@ def _check_duplicate(email: str, sheets: SheetsClient) -> None:
6560
raise Conflict("member already registered")
6661

6762

68-
def _verify_firebase(firebase_token: str) -> None:
69-
try:
70-
firebase_auth.verify_id_token(firebase_token)
71-
except Exception:
72-
raise BadRequest("invalid firebase phone token")
73-
74-
7563
def _now_kst() -> str:
7664
return datetime.now(_KST).strftime("%Y-%m-%d %H:%M:%S")
7765

7866

7967
def _build_row(
80-
member_id: str, profile: dict, school_email: str, phone: str, track: str
68+
member_id: str, name: str, email: str, school_email: str, phone: str, track: str
8169
) -> dict:
8270
now = _now_kst()
83-
base = _base_fields(member_id, profile["name"], profile["email"])
71+
base = _base_fields(member_id, name, email)
8472
extra = {"school_email": school_email, "phone": phone, "track": track}
8573
timestamps = {"join_date": now, "last_updated": now}
8674
return {**base, **extra, **timestamps}

src/bcsd_api/auth/verify.py

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
import time
44
from dataclasses import dataclass
55

6-
import aiosmtplib
7-
from email.message import EmailMessage
6+
from bcsd_api.email.sender import EmailSender
7+
from bcsd_api.email.template import verify_body
88

99

10-
_CODE_TTL = 300
10+
_CODE_TTL = 600
1111
_CODE_LENGTH = 6
12+
_SUBJECT = "[BCSD] 이메일 인증"
1213

1314

1415
@dataclass
@@ -24,24 +25,10 @@ def _generate_code() -> str:
2425
return "".join(random.choices(string.digits, k=_CODE_LENGTH))
2526

2627

27-
def _build_message(sender: str, recipient: str, code: str) -> EmailMessage:
28-
msg = EmailMessage()
29-
msg["From"] = sender
30-
msg["To"] = recipient
31-
msg["Subject"] = "[BCSDLab] Email Verification Code"
32-
msg.set_content(f"Your verification code is: {code}\nExpires in 5 minutes.")
33-
return msg
34-
35-
36-
async def send_code(email: str, host: str, port: int, user: str, password: str) -> None:
28+
def send_code(email: str, sender: EmailSender) -> None:
3729
code = _generate_code()
3830
_store[email] = _Pending(code=code, expires=time.time() + _CODE_TTL)
39-
msg = _build_message(user, email, code)
40-
await aiosmtplib.send(
41-
msg, hostname=host, port=port,
42-
username=user, password=password,
43-
use_tls=False, start_tls=True,
44-
)
31+
sender.send(to=email, subject=_SUBJECT, body=verify_body(code))
4532

4633

4734
def confirm_code(email: str, code: str) -> bool:

src/bcsd_api/config.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ class Settings(BaseSettings):
88
jwt_expire_minutes: int = 1440
99
google_sheets_id: str = ""
1010
google_service_account_file: str = "credentials.json"
11-
smtp_host: str = "smtp.gmail.com"
12-
smtp_port: int = 587
13-
smtp_user: str = ""
14-
smtp_password: str = ""
15-
firebase_credentials_file: str = "firebase-credentials.json"
16-
11+
resend_api_key: str = ""
12+
resend_sender: str = "onboarding@resend.dev"
13+
cors_origins: str = "http://localhost:3000"
14+
cookie_name: str = "access_token"
15+
cookie_secure: bool = False
1716
model_config = {"env_file": ".env"}

0 commit comments

Comments
 (0)