Skip to content

Commit d51da76

Browse files
hyeonsanghyeonsang
authored andcommitted
feat(backend): Wave 2a — JWT authentication system
1 parent 4b3c902 commit d51da76

9 files changed

Lines changed: 273 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,4 @@ local.properties
149149
.recommenders/
150150

151151
.github/agents/ai-strategy-consultant.md
152+
.history/

backend/app/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Application configuration via pydantic-settings."""
22

3+
import secrets
4+
5+
from pydantic import model_validator
36
from pydantic_settings import BaseSettings, SettingsConfigDict
47

58

@@ -13,6 +16,13 @@ class Settings(BaseSettings):
1316
MY_PW: str = "admin"
1417
SECRET_KEY: str = ""
1518

19+
@model_validator(mode="after")
20+
def _generate_secret_key(self) -> "Settings":
21+
"""Auto-generate SECRET_KEY if not provided."""
22+
if not self.SECRET_KEY:
23+
self.SECRET_KEY = secrets.token_urlsafe(32)
24+
return self
25+
1626
# 서버
1727
APP_PORT: int = 8000
1828
APP_HOST: str = "0.0.0.0"

backend/app/dependencies.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Shared FastAPI dependencies."""
2+
3+
from fastapi import Depends, HTTPException, status
4+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
5+
from jose import JWTError
6+
7+
from app.services.auth_service import decode_token
8+
9+
security = HTTPBearer()
10+
11+
12+
async def get_current_user(
13+
credentials: HTTPAuthorizationCredentials = Depends(security),
14+
) -> str:
15+
"""Validate JWT access token and return the username."""
16+
token = credentials.credentials
17+
try:
18+
payload = decode_token(token)
19+
if payload.get("type") != "access":
20+
raise HTTPException(
21+
status_code=status.HTTP_401_UNAUTHORIZED,
22+
detail="Invalid token type",
23+
)
24+
username: str = payload.get("sub")
25+
if username is None:
26+
raise HTTPException(
27+
status_code=status.HTTP_401_UNAUTHORIZED,
28+
detail="Invalid token payload",
29+
)
30+
return username
31+
except JWTError:
32+
raise HTTPException(
33+
status_code=status.HTTP_401_UNAUTHORIZED,
34+
detail="Invalid or expired token",
35+
headers={"WWW-Authenticate": "Bearer"},
36+
)

backend/app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from app.config import settings
1010
from app.database import init_db
1111
from app.models.download import Download # noqa: F401 — Base.metadata 등록용
12-
from app.routers import health
12+
from app.routers import auth, health
1313

1414

1515
@asynccontextmanager
@@ -34,3 +34,4 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
3434
)
3535

3636
app.include_router(health.router)
37+
app.include_router(auth.router)

backend/app/routers/auth.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Authentication endpoints."""
2+
3+
from fastapi import APIRouter, HTTPException, status
4+
from jose import JWTError
5+
6+
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
7+
from app.services.auth_service import (
8+
create_access_token,
9+
create_refresh_token,
10+
decode_token,
11+
verify_credentials,
12+
)
13+
14+
router = APIRouter(prefix="/api/auth", tags=["auth"])
15+
16+
17+
@router.post("/login", response_model=TokenResponse)
18+
async def login(request: LoginRequest) -> TokenResponse:
19+
"""Authenticate with ID/PW and receive JWT tokens."""
20+
if not verify_credentials(request.id, request.pw):
21+
raise HTTPException(
22+
status_code=status.HTTP_401_UNAUTHORIZED,
23+
detail="Invalid ID or password",
24+
)
25+
return TokenResponse(
26+
access_token=create_access_token(request.id),
27+
refresh_token=create_refresh_token(request.id),
28+
)
29+
30+
31+
@router.post("/refresh", response_model=TokenResponse)
32+
async def refresh(request: RefreshRequest) -> TokenResponse:
33+
"""Exchange a refresh token for a new access token."""
34+
try:
35+
payload = decode_token(request.refresh_token)
36+
if payload.get("type") != "refresh":
37+
raise HTTPException(
38+
status_code=status.HTTP_401_UNAUTHORIZED,
39+
detail="Invalid token type",
40+
)
41+
username = payload.get("sub")
42+
return TokenResponse(
43+
access_token=create_access_token(username),
44+
refresh_token=create_refresh_token(username),
45+
)
46+
except JWTError:
47+
raise HTTPException(
48+
status_code=status.HTTP_401_UNAUTHORIZED,
49+
detail="Invalid or expired refresh token",
50+
)

backend/app/schemas/auth.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Authentication request/response schemas."""
2+
3+
from pydantic import BaseModel
4+
5+
6+
class LoginRequest(BaseModel):
7+
"""Login request body."""
8+
9+
id: str
10+
pw: str
11+
12+
13+
class TokenResponse(BaseModel):
14+
"""JWT token response."""
15+
16+
access_token: str
17+
refresh_token: str
18+
token_type: str = "bearer"
19+
20+
21+
class RefreshRequest(BaseModel):
22+
"""Token refresh request body."""
23+
24+
refresh_token: str
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""JWT token creation and verification."""
2+
3+
from datetime import datetime, timedelta, timezone
4+
5+
from jose import jwt
6+
7+
from app.config import settings
8+
9+
ACCESS_TOKEN_EXPIRE_MINUTES = 30
10+
REFRESH_TOKEN_EXPIRE_DAYS = 7
11+
ALGORITHM = "HS256"
12+
13+
14+
def verify_credentials(user_id: str, password: str) -> bool:
15+
"""Check credentials against environment variables."""
16+
return user_id == settings.MY_ID and password == settings.MY_PW
17+
18+
19+
def create_access_token(subject: str) -> str:
20+
"""Create a JWT access token."""
21+
now = datetime.now(timezone.utc)
22+
payload = {
23+
"sub": subject,
24+
"type": "access",
25+
"exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
26+
"iat": now,
27+
}
28+
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
29+
30+
31+
def create_refresh_token(subject: str) -> str:
32+
"""Create a JWT refresh token."""
33+
now = datetime.now(timezone.utc)
34+
payload = {
35+
"sub": subject,
36+
"type": "refresh",
37+
"exp": now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
38+
"iat": now,
39+
}
40+
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
41+
42+
43+
def decode_token(token: str) -> dict:
44+
"""Decode and verify a JWT token. Raises JWTError on failure."""
45+
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])

backend/tests/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import pytest
2+
3+
from app.config import settings
4+
5+
6+
@pytest.fixture(autouse=True)
7+
def override_settings(monkeypatch):
8+
"""Ensure tests always use fixed credentials regardless of env vars."""
9+
monkeypatch.setattr(settings, "MY_ID", "admin")
10+
monkeypatch.setattr(settings, "MY_PW", "admin")
11+
monkeypatch.setattr(settings, "SECRET_KEY", "test-secret-key-for-testing")

backend/tests/test_auth.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Tests for authentication endpoints."""
2+
3+
import pytest
4+
from httpx import ASGITransport, AsyncClient
5+
6+
from app.main import app
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_login_success() -> None:
11+
"""Valid credentials should return access + refresh tokens."""
12+
transport = ASGITransport(app=app)
13+
async with AsyncClient(transport=transport, base_url="http://test") as client:
14+
response = await client.post(
15+
"/api/auth/login",
16+
json={"id": "admin", "pw": "admin"},
17+
)
18+
assert response.status_code == 200
19+
data = response.json()
20+
assert "access_token" in data
21+
assert "refresh_token" in data
22+
assert data["token_type"] == "bearer"
23+
24+
25+
@pytest.mark.asyncio
26+
async def test_login_invalid_password() -> None:
27+
"""Wrong password should return 401."""
28+
transport = ASGITransport(app=app)
29+
async with AsyncClient(transport=transport, base_url="http://test") as client:
30+
response = await client.post(
31+
"/api/auth/login",
32+
json={"id": "admin", "pw": "wrongpassword"},
33+
)
34+
assert response.status_code == 401
35+
36+
37+
@pytest.mark.asyncio
38+
async def test_login_invalid_id() -> None:
39+
"""Wrong ID should return 401."""
40+
transport = ASGITransport(app=app)
41+
async with AsyncClient(transport=transport, base_url="http://test") as client:
42+
response = await client.post(
43+
"/api/auth/login",
44+
json={"id": "wronguser", "pw": "admin"},
45+
)
46+
assert response.status_code == 401
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_refresh_token() -> None:
51+
"""Valid refresh token should return new access token."""
52+
transport = ASGITransport(app=app)
53+
async with AsyncClient(transport=transport, base_url="http://test") as client:
54+
login_resp = await client.post(
55+
"/api/auth/login",
56+
json={"id": "admin", "pw": "admin"},
57+
)
58+
refresh_token = login_resp.json()["refresh_token"]
59+
60+
refresh_resp = await client.post(
61+
"/api/auth/refresh",
62+
json={"refresh_token": refresh_token},
63+
)
64+
assert refresh_resp.status_code == 200
65+
data = refresh_resp.json()
66+
assert "access_token" in data
67+
assert "refresh_token" in data
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_refresh_with_access_token_fails() -> None:
72+
"""Using access token as refresh token should fail."""
73+
transport = ASGITransport(app=app)
74+
async with AsyncClient(transport=transport, base_url="http://test") as client:
75+
login_resp = await client.post(
76+
"/api/auth/login",
77+
json={"id": "admin", "pw": "admin"},
78+
)
79+
access_token = login_resp.json()["access_token"]
80+
81+
refresh_resp = await client.post(
82+
"/api/auth/refresh",
83+
json={"refresh_token": access_token},
84+
)
85+
assert refresh_resp.status_code == 401
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_health_endpoint_without_token() -> None:
90+
"""Health check should work without authentication."""
91+
transport = ASGITransport(app=app)
92+
async with AsyncClient(transport=transport, base_url="http://test") as client:
93+
response = await client.get("/api/health")
94+
assert response.status_code == 200

0 commit comments

Comments
 (0)