feat(auth): 카카오 로그인 — o2o-castad-backend 인증 스택 이식
백엔드 (castad 와 동일 흐름, triplepick 스타일로 재작성):
- users/refresh_tokens 테이블 (신규 — create_all 로 자동 생성)
- 카카오 OAuth (code → 토큰 → 사용자 정보), aiohttp 대신 httpx
- JWT HS256 access 60분 / refresh 7일, Refresh Token Rotation
(재사용 감지 시 TOKEN_REVOKED, castad 동일 에러 코드 체계)
- /api/auth/{kakao/login, kakao/callback, refresh, logout, me}
- 콜백 → 프론트 리다이렉트는 쿼리 대신 해시 프래그먼트로 토큰 전달
(서버 로그·Referer 에 토큰 노출 방지 — castad 방식에서 한 단계 보강)
- 크레딧·소셜계정 연동 등 castad 전용 개념은 제외
프론트:
- lib/auth.ts — 토큰 보관·자동 갱신·/me 캐시·useAuth 훅
- Hero 에 카카오 로그인 버튼(브랜드 노랑) / 프로필 칩 + 로그아웃
- 투표 폼(Arena) 이메일을 로그인 계정 이메일로 자동 채움
(기존 이메일 기반 투표·리더보드 파이프라인은 그대로 유지)
설정: KAKAO_CLIENT_ID/SECRET/REDIRECT_URI, JWT_SECRET (.env.example 참고)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e2d1647100
commit
7705099c64
@ -45,6 +45,15 @@ STATUS_TICK_SECONDS=60
|
|||||||
# 관리자 (강한 토큰으로 교체)
|
# 관리자 (강한 토큰으로 교체)
|
||||||
ADMIN_API_TOKEN=change-me-admin-token
|
ADMIN_API_TOKEN=change-me-admin-token
|
||||||
|
|
||||||
|
# ── 카카오 로그인 (o2o-castad-backend 인증 이식) ──
|
||||||
|
# 카카오 개발자 콘솔(developers.kakao.com) 앱의 REST API 키.
|
||||||
|
# redirect_uri 는 콘솔 [카카오 로그인 > Redirect URI] 에 등록돼 있어야 한다.
|
||||||
|
# 운영: https://triplepick.o2o.kr/api/auth/kakao/callback
|
||||||
|
KAKAO_CLIENT_ID=
|
||||||
|
KAKAO_CLIENT_SECRET= # 콘솔에서 Client Secret 활성화한 경우만
|
||||||
|
KAKAO_REDIRECT_URI=http://localhost:8080/api/auth/kakao/callback
|
||||||
|
JWT_SECRET=change-me-triplepick-jwt-secret # 32자 이상 무작위 문자열로 교체
|
||||||
|
|
||||||
# ── 외부 연동: AI 3모델 (실연동, 키 없으면 해당 모델 생성 생략) ──
|
# ── 외부 연동: AI 3모델 (실연동, 키 없으면 해당 모델 생성 생략) ──
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_MODEL=gpt-4o
|
OPENAI_MODEL=gpt-4o
|
||||||
|
|||||||
@ -97,6 +97,16 @@ class Settings(BaseSettings):
|
|||||||
naver_api_base: str = "https://api-gw.sports.naver.com"
|
naver_api_base: str = "https://api-gw.sports.naver.com"
|
||||||
# MLB 공식 Stats API (키 불필요).
|
# MLB 공식 Stats API (키 불필요).
|
||||||
mlb_api_base: str = "https://statsapi.mlb.com/api"
|
mlb_api_base: str = "https://statsapi.mlb.com/api"
|
||||||
|
# ── 카카오 로그인 (o2o-castad-backend 인증 이식) ──────────
|
||||||
|
# 카카오 개발자 콘솔의 REST API 키. redirect_uri 는 콘솔에 등록돼 있어야 한다.
|
||||||
|
kakao_client_id: str = ""
|
||||||
|
kakao_client_secret: str = "" # 콘솔에서 활성화한 경우만
|
||||||
|
kakao_redirect_uri: str = "http://localhost:8080/api/auth/kakao/callback"
|
||||||
|
# JWT — castad 와 동일: HS256, access 60분 / refresh 7일 (rotation)
|
||||||
|
jwt_secret: str = "change-me-triplepick-jwt-secret"
|
||||||
|
jwt_access_expire_minutes: int = 60
|
||||||
|
jwt_refresh_expire_days: int = 7
|
||||||
|
|
||||||
# ESPN 비공식 API (MLS 일정·결과·순위·상세) — 키 불필요, 비공식.
|
# ESPN 비공식 API (MLS 일정·결과·순위·상세) — 키 불필요, 비공식.
|
||||||
espn_api_base: str = "https://site.api.espn.com/apis"
|
espn_api_base: str = "https://site.api.espn.com/apis"
|
||||||
espn_mls_path: str = "sports/soccer/usa.1"
|
espn_mls_path: str = "sports/soccer/usa.1"
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from .config import settings
|
|||||||
from .database import init_db
|
from .database import init_db
|
||||||
from .routers import (
|
from .routers import (
|
||||||
admin,
|
admin,
|
||||||
|
auth,
|
||||||
comments,
|
comments,
|
||||||
leaderboard,
|
leaderboard,
|
||||||
matches,
|
matches,
|
||||||
@ -49,6 +50,7 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
app.include_router(matches.router)
|
app.include_router(matches.router)
|
||||||
app.include_router(comments.router)
|
app.include_router(comments.router)
|
||||||
app.include_router(predictions.router)
|
app.include_router(predictions.router)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from datetime import date, datetime
|
|||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
JSON,
|
JSON,
|
||||||
|
BigInteger,
|
||||||
Boolean,
|
Boolean,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
@ -250,6 +251,53 @@ class FootballCache(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""카카오 소셜 로그인 사용자 (o2o-castad-backend 인증 이식).
|
||||||
|
|
||||||
|
투표(user_predictions)는 기존 이메일 식별을 유지하고,
|
||||||
|
로그인 시 카카오 이메일을 투표 이메일로 자동 사용해 연결한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
kakao_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
|
||||||
|
user_uuid: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
nickname: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
profile_image_url: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(Base):
|
||||||
|
"""리프레시 토큰 (해시 저장·회전 시 폐기). castad 와 동일한 rotation 방식."""
|
||||||
|
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
user_uuid: Mapped[str] = mapped_column(String, index=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
is_revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DataCache(Base):
|
class DataCache(Base):
|
||||||
"""야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용.
|
"""야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용.
|
||||||
|
|
||||||
|
|||||||
76
backend/app/routers/auth.py
Normal file
76
backend/app/routers/auth.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"""인증 API — 카카오 로그인·토큰 갱신·로그아웃·내 정보 (castad 이식).
|
||||||
|
|
||||||
|
콜백은 백엔드가 받아 JWT 발급 후 프론트로 `#access_token=..&refresh_token=..`
|
||||||
|
해시 프래그먼트로 리다이렉트한다 (쿼리스트링이 아니라 해시 — 서버 로그·Referer 에
|
||||||
|
토큰이 남지 않게 castad 방식에서 한 단계 보강).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, Request, status
|
||||||
|
from fastapi.responses import RedirectResponse, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import User
|
||||||
|
from ..services import auth_kakao
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshIn(BaseModel):
|
||||||
|
refreshToken: str
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str | None:
|
||||||
|
fwd = request.headers.get("X-Forwarded-For")
|
||||||
|
if fwd:
|
||||||
|
return fwd.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/kakao/login")
|
||||||
|
async def kakao_login_url() -> dict:
|
||||||
|
"""카카오 인증 페이지 URL — 프론트가 이 URL 로 이동시킨다."""
|
||||||
|
return {"authUrl": auth_kakao.kakao_authorization_url()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/kakao/callback")
|
||||||
|
async def kakao_callback(
|
||||||
|
request: Request,
|
||||||
|
code: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user_agent: Optional[str] = Header(None, alias="User-Agent"),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""카카오 콜백 — 인가 코드로 JWT 발급 후 프론트로 리다이렉트."""
|
||||||
|
result = await auth_kakao.kakao_login(
|
||||||
|
db, code, user_agent=user_agent, ip_address=_client_ip(request)
|
||||||
|
)
|
||||||
|
redirect = (
|
||||||
|
f"{settings.public_origin}/"
|
||||||
|
f"#access_token={result['access_token']}"
|
||||||
|
f"&refresh_token={result['refresh_token']}"
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=redirect, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
async def refresh(body: RefreshIn, db: AsyncSession = Depends(get_db)) -> dict:
|
||||||
|
"""리프레시 토큰 회전 — 새 access/refresh 발급, 기존 refresh 폐기."""
|
||||||
|
return await auth_kakao.refresh_tokens(db, body.refreshToken)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def logout(body: RefreshIn, db: AsyncSession = Depends(get_db)) -> Response:
|
||||||
|
"""리프레시 토큰 폐기. access 만료 후엔 재갱신 불가."""
|
||||||
|
await auth_kakao.logout(db, body.refreshToken)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(user: User = Depends(auth_kakao.get_current_user)) -> dict:
|
||||||
|
"""현재 로그인 사용자 정보."""
|
||||||
|
return auth_kakao.user_out(user)
|
||||||
342
backend/app/services/auth_kakao.py
Normal file
342
backend/app/services/auth_kakao.py
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
"""카카오 로그인 + JWT 인증 — o2o-castad-backend 의 인증 스택 이식.
|
||||||
|
|
||||||
|
흐름 (castad 와 동일):
|
||||||
|
1. GET /api/auth/kakao/login → 카카오 인증 페이지 URL
|
||||||
|
2. 카카오 로그인 후 redirect_uri 로 인가 코드(code) 수신
|
||||||
|
3. 코드 → 카카오 액세스 토큰 → 사용자 정보 조회
|
||||||
|
4. users 조회/생성 → JWT access(60분)/refresh(7일) 발급
|
||||||
|
5. refresh 는 해시로 DB 저장, 갱신 시 rotation(기존 폐기 + 신규 발급)
|
||||||
|
|
||||||
|
castad 대비 변경: aiohttp→httpx(프로젝트 표준), 크레딧·소셜계정 연동 제거,
|
||||||
|
설정은 triplepick settings 로 통합.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from jose import jwt
|
||||||
|
from jose.exceptions import ExpiredSignatureError, JWTError
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..database import get_db
|
||||||
|
from ..domain import ensure_aware, now_utc
|
||||||
|
from ..models import RefreshToken, User
|
||||||
|
|
||||||
|
log = logging.getLogger("triplepick.auth")
|
||||||
|
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
|
||||||
|
KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"
|
||||||
|
KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 예외 (castad 코드 체계 유지) ───────────────────────────────
|
||||||
|
class AuthError(HTTPException):
|
||||||
|
def __init__(self, status_code: int, code: str, message: str):
|
||||||
|
super().__init__(
|
||||||
|
status_code=status_code, detail={"code": code, "message": message}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unauthorized(code: str, message: str) -> AuthError:
|
||||||
|
return AuthError(status.HTTP_401_UNAUTHORIZED, code, message)
|
||||||
|
|
||||||
|
|
||||||
|
# ── JWT ────────────────────────────────────────────────────────
|
||||||
|
def create_access_token(user_uuid: str) -> str:
|
||||||
|
expire = now_utc() + timedelta(minutes=settings.jwt_access_expire_minutes)
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": user_uuid, "exp": expire, "type": "access"},
|
||||||
|
settings.jwt_secret,
|
||||||
|
algorithm=JWT_ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(user_uuid: str) -> str:
|
||||||
|
expire = now_utc() + timedelta(days=settings.jwt_refresh_expire_days)
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": user_uuid, "exp": expire, "type": "refresh"},
|
||||||
|
settings.jwt_secret,
|
||||||
|
algorithm=JWT_ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict | None:
|
||||||
|
"""유효하면 payload, 만료/위조면 None."""
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_token_expired(token: str) -> bool:
|
||||||
|
try:
|
||||||
|
jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
|
||||||
|
return False
|
||||||
|
except ExpiredSignatureError:
|
||||||
|
return True
|
||||||
|
except JWTError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_hash(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 카카오 OAuth ───────────────────────────────────────────────
|
||||||
|
def kakao_authorization_url() -> str:
|
||||||
|
return (
|
||||||
|
f"{KAKAO_AUTH_URL}?client_id={settings.kakao_client_id}"
|
||||||
|
f"&redirect_uri={settings.kakao_redirect_uri}&response_type=code"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _kakao_access_token(code: str) -> str:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": settings.kakao_client_id,
|
||||||
|
"redirect_uri": settings.kakao_redirect_uri,
|
||||||
|
"code": code,
|
||||||
|
}
|
||||||
|
if settings.kakao_client_secret:
|
||||||
|
data["client_secret"] = settings.kakao_client_secret
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
r = await c.post(KAKAO_TOKEN_URL, data=data)
|
||||||
|
result = r.json()
|
||||||
|
if "error" in result:
|
||||||
|
desc = result.get("error_description", result.get("error", "알 수 없는 오류"))
|
||||||
|
log.error("kakao 토큰 발급 실패: %s", desc)
|
||||||
|
raise AuthError(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED",
|
||||||
|
f"카카오 토큰 발급 실패: {desc}",
|
||||||
|
)
|
||||||
|
return result["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _kakao_user_info(access_token: str) -> dict:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
r = await c.get(
|
||||||
|
KAKAO_USER_INFO_URL, headers={"Authorization": f"Bearer {access_token}"}
|
||||||
|
)
|
||||||
|
result = r.json()
|
||||||
|
if "id" not in result:
|
||||||
|
log.error("kakao 사용자 정보 조회 실패: %s", result)
|
||||||
|
raise AuthError(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED",
|
||||||
|
"카카오 사용자 정보를 가져올 수 없습니다.",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── 사용자 조회/생성 ───────────────────────────────────────────
|
||||||
|
def _profile_of(info: dict) -> tuple[str | None, str | None, str | None]:
|
||||||
|
"""카카오 응답 → (email, nickname, profile_image_url)."""
|
||||||
|
account = info.get("kakao_account") or {}
|
||||||
|
profile = account.get("profile") or {}
|
||||||
|
return (
|
||||||
|
account.get("email"),
|
||||||
|
profile.get("nickname"),
|
||||||
|
profile.get("profile_image_url"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_user(db: AsyncSession, info: dict) -> tuple[User, bool]:
|
||||||
|
kakao_id = int(info["id"])
|
||||||
|
email, nickname, image = _profile_of(info)
|
||||||
|
|
||||||
|
user = (
|
||||||
|
await db.execute(select(User).where(User.kakao_id == kakao_id))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user is not None:
|
||||||
|
# 기존 사용자 — 프로필 최신화
|
||||||
|
if nickname:
|
||||||
|
user.nickname = nickname
|
||||||
|
if image:
|
||||||
|
user.profile_image_url = image
|
||||||
|
if email:
|
||||||
|
user.email = email
|
||||||
|
await db.flush()
|
||||||
|
return user, False
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
new_user = User(
|
||||||
|
kakao_id=kakao_id,
|
||||||
|
user_uuid=str(uuid.uuid4()),
|
||||||
|
email=email,
|
||||||
|
nickname=nickname,
|
||||||
|
profile_image_url=image,
|
||||||
|
)
|
||||||
|
db.add(new_user)
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
return new_user, True
|
||||||
|
except IntegrityError:
|
||||||
|
# 동시 요청으로 인한 중복 삽입 — 기존 사용자 재조회 (castad 동일 처리)
|
||||||
|
await db.rollback()
|
||||||
|
existing = (
|
||||||
|
await db.execute(select(User).where(User.kakao_id == kakao_id))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing, False
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ── 로그인/갱신/로그아웃 ───────────────────────────────────────
|
||||||
|
async def kakao_login(
|
||||||
|
db: AsyncSession,
|
||||||
|
code: str,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""인가 코드 → JWT 발급. {access_token, refresh_token, expires_in, is_new_user, user}."""
|
||||||
|
kakao_token = await _kakao_access_token(code)
|
||||||
|
info = await _kakao_user_info(kakao_token)
|
||||||
|
user, is_new = await _get_or_create_user(db, info)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise AuthError(
|
||||||
|
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = create_access_token(user.user_uuid)
|
||||||
|
refresh_token = create_refresh_token(user.user_uuid)
|
||||||
|
db.add(RefreshToken(
|
||||||
|
user_id=user.id,
|
||||||
|
user_uuid=user.user_uuid,
|
||||||
|
token_hash=get_token_hash(refresh_token),
|
||||||
|
expires_at=now_utc() + timedelta(days=settings.jwt_refresh_expire_days),
|
||||||
|
user_agent=user_agent,
|
||||||
|
ip_address=ip_address,
|
||||||
|
))
|
||||||
|
user.last_login_at = now_utc()
|
||||||
|
await db.commit()
|
||||||
|
log.info("kakao 로그인: user_id=%s new=%s", user.id, is_new)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": settings.jwt_access_expire_minutes * 60,
|
||||||
|
"is_new_user": is_new,
|
||||||
|
"user": user_out(user),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_tokens(db: AsyncSession, refresh_token: str) -> dict:
|
||||||
|
"""Refresh Token Rotation — 기존 토큰 폐기 + 새 access/refresh 발급."""
|
||||||
|
payload = decode_token(refresh_token)
|
||||||
|
if payload is None:
|
||||||
|
if is_token_expired(refresh_token):
|
||||||
|
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
|
||||||
|
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
raise _unauthorized("INVALID_TOKEN", "리프레시 토큰이 아닙니다.")
|
||||||
|
|
||||||
|
token_hash = get_token_hash(refresh_token)
|
||||||
|
db_token = (
|
||||||
|
await db.execute(
|
||||||
|
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if db_token is None:
|
||||||
|
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
|
||||||
|
if db_token.is_revoked: # 이미 폐기된 토큰 재사용 — replay 의심
|
||||||
|
log.warning("폐기된 refresh 재사용: user_uuid=%s", db_token.user_uuid)
|
||||||
|
raise _unauthorized("TOKEN_REVOKED", "취소된 토큰입니다. 다시 로그인해주세요.")
|
||||||
|
if ensure_aware(db_token.expires_at) < now_utc():
|
||||||
|
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
|
||||||
|
|
||||||
|
user = (
|
||||||
|
await db.execute(select(User).where(User.user_uuid == payload.get("sub")))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise AuthError(
|
||||||
|
status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", "가입되지 않은 사용자 입니다."
|
||||||
|
)
|
||||||
|
if not user.is_active:
|
||||||
|
raise AuthError(
|
||||||
|
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
db_token.is_revoked = True
|
||||||
|
db_token.revoked_at = now_utc()
|
||||||
|
new_access = create_access_token(user.user_uuid)
|
||||||
|
new_refresh = create_refresh_token(user.user_uuid)
|
||||||
|
db.add(RefreshToken(
|
||||||
|
user_id=user.id,
|
||||||
|
user_uuid=user.user_uuid,
|
||||||
|
token_hash=get_token_hash(new_refresh),
|
||||||
|
expires_at=now_utc() + timedelta(days=settings.jwt_refresh_expire_days),
|
||||||
|
))
|
||||||
|
await db.commit() # 폐기 + 저장을 한 트랜잭션으로
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": new_access,
|
||||||
|
"refresh_token": new_refresh,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": settings.jwt_access_expire_minutes * 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def logout(db: AsyncSession, refresh_token: str) -> None:
|
||||||
|
await db.execute(
|
||||||
|
update(RefreshToken)
|
||||||
|
.where(RefreshToken.token_hash == get_token_hash(refresh_token))
|
||||||
|
.values(is_revoked=True, revoked_at=now_utc())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def user_out(user: User) -> dict:
|
||||||
|
return {
|
||||||
|
"userUuid": user.user_uuid,
|
||||||
|
"email": user.email,
|
||||||
|
"nickname": user.nickname,
|
||||||
|
"profileImageUrl": user.profile_image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── FastAPI 의존성 ─────────────────────────────────────────────
|
||||||
|
_security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_security),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
"""Bearer access 토큰 → User. 실패 시 401/403/404."""
|
||||||
|
if credentials is None:
|
||||||
|
raise _unauthorized("MISSING_TOKEN", "인증 토큰이 필요합니다.")
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
if payload is None:
|
||||||
|
if is_token_expired(credentials.credentials):
|
||||||
|
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
|
||||||
|
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
|
||||||
|
if payload.get("type") != "access":
|
||||||
|
raise _unauthorized("INVALID_TOKEN", "액세스 토큰이 아닙니다.")
|
||||||
|
user = (
|
||||||
|
await db.execute(select(User).where(User.user_uuid == payload.get("sub")))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise AuthError(
|
||||||
|
status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", "가입되지 않은 사용자 입니다."
|
||||||
|
)
|
||||||
|
if not user.is_active:
|
||||||
|
raise AuthError(
|
||||||
|
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
|
||||||
|
)
|
||||||
|
return user
|
||||||
@ -10,6 +10,7 @@ aiosmtplib==3.0.2
|
|||||||
azure-communication-email==1.0.0
|
azure-communication-email==1.0.0
|
||||||
aiohttp==3.10.11
|
aiohttp==3.10.11
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
|
python-jose[cryptography]==3.5.0
|
||||||
anthropic==0.69.0
|
anthropic==0.69.0
|
||||||
openai==1.59.6
|
openai==1.59.6
|
||||||
google-genai==0.8.0
|
google-genai==0.8.0
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { outcomeLabel, pctParts, kickoffDisplay, winProb } from "@/lib/format";
|
import { outcomeLabel, pctParts, kickoffDisplay, winProb } from "@/lib/format";
|
||||||
import { type Lang, dict, teamShort } from "@/lib/i18n";
|
import { type Lang, dict, teamShort } from "@/lib/i18n";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
import type {
|
import type {
|
||||||
Outcome,
|
Outcome,
|
||||||
CrowdStats,
|
CrowdStats,
|
||||||
@ -112,6 +113,11 @@ export default function Arena({
|
|||||||
const [step, setStep] = useState<Step>("form");
|
const [step, setStep] = useState<Step>("form");
|
||||||
const [recentEmails, setRecentEmails] = useState<string[]>(getRecentEmails);
|
const [recentEmails, setRecentEmails] = useState<string[]>(getRecentEmails);
|
||||||
const [email, setEmail] = useState(""); // 칸은 비우고, 입력/포커스 시 datalist 드롭다운으로만 자동완성
|
const [email, setEmail] = useState(""); // 칸은 비우고, 입력/포커스 시 datalist 드롭다운으로만 자동완성
|
||||||
|
// 카카오 로그인 시 계정 이메일을 투표 이메일로 자동 채움 (수정은 자유)
|
||||||
|
const { user: authUser } = useAuth();
|
||||||
|
useEffect(() => {
|
||||||
|
if (authUser?.email) setEmail((cur) => cur || authUser.email!);
|
||||||
|
}, [authUser]);
|
||||||
const [notify, setNotify] = useState(true);
|
const [notify, setNotify] = useState(true);
|
||||||
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|||||||
@ -2,8 +2,54 @@ import { Link, useNavigate } from "react-router-dom";
|
|||||||
import ShareButton from "./ShareButton";
|
import ShareButton from "./ShareButton";
|
||||||
import LangSwitch from "./LangSwitch";
|
import LangSwitch from "./LangSwitch";
|
||||||
import { type Lang, dict } from "@/lib/i18n";
|
import { type Lang, dict } from "@/lib/i18n";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
import type { League } from "@/lib/types";
|
import type { League } from "@/lib/types";
|
||||||
|
|
||||||
|
// 카카오 로그인 버튼(브랜드 노랑) ↔ 로그인 시 프로필 칩 + 로그아웃
|
||||||
|
function AuthChip({ lang }: { lang: Lang }) {
|
||||||
|
const { user, login, logout } = useAuth();
|
||||||
|
if (user) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="flex max-w-[130px] items-center gap-1.5 rounded-full border border-white/12 bg-white/8 py-1 pl-1 pr-2.5">
|
||||||
|
{user.profileImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={user.profileImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-5 w-5 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="grid h-5 w-5 place-items-center rounded-full bg-white/15 text-[10px]">
|
||||||
|
👤
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate text-[11px] font-bold text-white/85">
|
||||||
|
{user.nickname || user.email || "회원"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="rounded-full border border-white/12 px-2 py-1 text-[10px] font-bold text-white/45 transition active:scale-95"
|
||||||
|
>
|
||||||
|
{lang === "en" ? "Sign out" : "로그아웃"}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={login}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-[#FEE500] px-2.5 py-1 text-[11px] font-extrabold text-[#191919] transition active:scale-95"
|
||||||
|
>
|
||||||
|
{/* 카카오 말풍선 심볼 */}
|
||||||
|
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="#191919">
|
||||||
|
<path d="M12 3C6.48 3 2 6.58 2 11c0 2.84 1.87 5.33 4.68 6.75-.15.53-.97 3.36-1 3.58 0 0-.02.17.09.24.11.07.24.02.24.02.32-.05 3.7-2.42 4.28-2.83.55.08 1.12.12 1.71.12 5.52 0 10-3.58 10-8S17.52 3 12 3z" />
|
||||||
|
</svg>
|
||||||
|
{lang === "en" ? "Sign in" : "로그인"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Share = { url: string; title: string; text: string };
|
type Share = { url: string; title: string; text: string };
|
||||||
|
|
||||||
// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출.
|
// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출.
|
||||||
@ -31,7 +77,7 @@ export default function Hero({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<header className="pt-5 text-center">
|
<header className="pt-5 text-center">
|
||||||
{back && (
|
{back ? (
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<Link
|
<Link
|
||||||
to={home}
|
to={home}
|
||||||
@ -40,9 +86,16 @@ export default function Hero({
|
|||||||
>
|
>
|
||||||
{t.back}
|
{t.back}
|
||||||
</Link>
|
</Link>
|
||||||
{share && (
|
<span className="flex items-center gap-2">
|
||||||
<ShareButton {...share} label={t.share} copiedLabel={t.shareCopied} />
|
<AuthChip lang={lang} />
|
||||||
)}
|
{share && (
|
||||||
|
<ShareButton {...share} label={t.share} copiedLabel={t.shareCopied} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-1 flex justify-end">
|
||||||
|
<AuthChip lang={lang} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="font-impact text-[44px] leading-none tracking-tight">
|
<div className="font-impact text-[44px] leading-none tracking-tight">
|
||||||
|
|||||||
130
frontend/src/lib/auth.ts
Normal file
130
frontend/src/lib/auth.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// 카카오 로그인 클라이언트 (o2o-castad 인증 이식과 짝).
|
||||||
|
// 토큰은 localStorage 보관, access 만료 시 refresh 로 1회 자동 갱신.
|
||||||
|
// 콜백은 백엔드가 `/#access_token=..&refresh_token=..` 으로 리다이렉트 — 앱 시작 시 캡처.
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const BASE = import.meta.env.VITE_API_BASE || "/api";
|
||||||
|
const KEY_ACCESS = "tp.auth.access";
|
||||||
|
const KEY_REFRESH = "tp.auth.refresh";
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
userUuid: string;
|
||||||
|
email: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
profileImageUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 콜백 해시에서 토큰 캡처 (main.tsx 렌더 전에 호출) — URL 에 토큰이 남지 않게 즉시 제거
|
||||||
|
export function captureAuthTokens(): void {
|
||||||
|
const h = window.location.hash;
|
||||||
|
if (!h.includes("access_token=")) return;
|
||||||
|
const params = new URLSearchParams(h.slice(1));
|
||||||
|
const access = params.get("access_token");
|
||||||
|
const refresh = params.get("refresh_token");
|
||||||
|
if (access && refresh) {
|
||||||
|
localStorage.setItem(KEY_ACCESS, access);
|
||||||
|
localStorage.setItem(KEY_REFRESH, refresh);
|
||||||
|
userCache = undefined; // 다음 getMe 에서 재조회
|
||||||
|
}
|
||||||
|
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoggedIn(): boolean {
|
||||||
|
return !!localStorage.getItem(KEY_REFRESH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTokens(): void {
|
||||||
|
localStorage.removeItem(KEY_ACCESS);
|
||||||
|
localStorage.removeItem(KEY_REFRESH);
|
||||||
|
userCache = null;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryRefresh(): Promise<boolean> {
|
||||||
|
const refresh = localStorage.getItem(KEY_REFRESH);
|
||||||
|
if (!refresh) return false;
|
||||||
|
const res = await fetch(`${BASE}/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refreshToken: refresh }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
clearTokens(); // 만료/폐기 — 재로그인 필요
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const d = await res.json();
|
||||||
|
localStorage.setItem(KEY_ACCESS, d.access_token);
|
||||||
|
localStorage.setItem(KEY_REFRESH, d.refresh_token); // rotation — 새 refresh 로 교체
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증 헤더로 호출, 401 이면 refresh 후 1회 재시도
|
||||||
|
async function authFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const call = () =>
|
||||||
|
fetch(`${BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init?.headers || {}),
|
||||||
|
Authorization: `Bearer ${localStorage.getItem(KEY_ACCESS) || ""}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let res = await call();
|
||||||
|
if (res.status === 401 && (await tryRefresh())) res = await call();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /me 는 모듈 레벨 캐시 — 화면 여러 곳(Hero·Arena)에서 써도 1회만 조회
|
||||||
|
let userCache: AuthUser | null | undefined; // undefined=미조회, null=비로그인
|
||||||
|
let inflight: Promise<AuthUser | null> | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const notify = () => listeners.forEach((fn) => fn());
|
||||||
|
|
||||||
|
export async function getMe(): Promise<AuthUser | null> {
|
||||||
|
if (userCache !== undefined) return userCache;
|
||||||
|
if (!isLoggedIn()) return (userCache = null);
|
||||||
|
inflight ??= (async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch("/auth/me");
|
||||||
|
userCache = res.ok ? ((await res.json()) as AuthUser) : null;
|
||||||
|
} catch {
|
||||||
|
userCache = null;
|
||||||
|
} finally {
|
||||||
|
inflight = null;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
return userCache;
|
||||||
|
})();
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startKakaoLogin(): Promise<void> {
|
||||||
|
const res = await fetch(`${BASE}/auth/kakao/login`);
|
||||||
|
const d = await res.json();
|
||||||
|
window.location.href = d.authUrl; // 카카오 인증 페이지로 이동
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
const refresh = localStorage.getItem(KEY_REFRESH);
|
||||||
|
if (refresh) {
|
||||||
|
await fetch(`${BASE}/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refreshToken: refresh }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
clearTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인 상태 훅 — user(null=비로그인) 와 로그인/로그아웃 액션
|
||||||
|
export function useAuth() {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(userCache ?? null);
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setUser(userCache ?? null);
|
||||||
|
listeners.add(sync);
|
||||||
|
getMe().then(sync);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(sync);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return { user, login: startKakaoLogin, logout };
|
||||||
|
}
|
||||||
@ -2,8 +2,11 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { captureAuthTokens } from "./lib/auth";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
captureAuthTokens(); // 카카오 콜백 해시(#access_token=..) 캡처 — 라우터 마운트 전에
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user