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
|
||||
|
||||
# ── 카카오 로그인 (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모델 (실연동, 키 없으면 해당 모델 생성 생략) ──
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o
|
||||
|
||||
@ -97,6 +97,16 @@ class Settings(BaseSettings):
|
||||
naver_api_base: str = "https://api-gw.sports.naver.com"
|
||||
# MLB 공식 Stats 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_base: str = "https://site.api.espn.com/apis"
|
||||
espn_mls_path: str = "sports/soccer/usa.1"
|
||||
|
||||
@ -15,6 +15,7 @@ from .config import settings
|
||||
from .database import init_db
|
||||
from .routers import (
|
||||
admin,
|
||||
auth,
|
||||
comments,
|
||||
leaderboard,
|
||||
matches,
|
||||
@ -49,6 +50,7 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(matches.router)
|
||||
app.include_router(comments.router)
|
||||
app.include_router(predictions.router)
|
||||
|
||||
@ -13,6 +13,7 @@ from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Date,
|
||||
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):
|
||||
"""야구(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
|
||||
aiohttp==3.10.11
|
||||
httpx==0.27.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
anthropic==0.69.0
|
||||
openai==1.59.6
|
||||
google-genai==0.8.0
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { outcomeLabel, pctParts, kickoffDisplay, winProb } from "@/lib/format";
|
||||
import { type Lang, dict, teamShort } from "@/lib/i18n";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import type {
|
||||
Outcome,
|
||||
CrowdStats,
|
||||
@ -112,6 +113,11 @@ export default function Arena({
|
||||
const [step, setStep] = useState<Step>("form");
|
||||
const [recentEmails, setRecentEmails] = useState<string[]>(getRecentEmails);
|
||||
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 [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@ -2,8 +2,54 @@ import { Link, useNavigate } from "react-router-dom";
|
||||
import ShareButton from "./ShareButton";
|
||||
import LangSwitch from "./LangSwitch";
|
||||
import { type Lang, dict } from "@/lib/i18n";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
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 };
|
||||
|
||||
// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출.
|
||||
@ -31,7 +77,7 @@ export default function Hero({
|
||||
};
|
||||
return (
|
||||
<header className="pt-5 text-center">
|
||||
{back && (
|
||||
{back ? (
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Link
|
||||
to={home}
|
||||
@ -40,9 +86,16 @@ export default function Hero({
|
||||
>
|
||||
{t.back}
|
||||
</Link>
|
||||
{share && (
|
||||
<ShareButton {...share} label={t.share} copiedLabel={t.shareCopied} />
|
||||
)}
|
||||
<span className="flex items-center gap-2">
|
||||
<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 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 { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { captureAuthTokens } from "./lib/auth";
|
||||
import "./globals.css";
|
||||
|
||||
captureAuthTokens(); // 카카오 콜백 해시(#access_token=..) 캡처 — 라우터 마운트 전에
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user