From 7705099c64a2732423d943ee4927b805935d536d Mon Sep 17 00:00:00 2001 From: jwkim Date: Fri, 14 Aug 2026 15:42:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=E2=80=94=20o2o-castad-backend=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=8A=A4=ED=83=9D=20=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드 (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 --- backend/.env.example | 9 + backend/app/config.py | 10 + backend/app/main.py | 2 + backend/app/models.py | 48 ++++ backend/app/routers/auth.py | 76 +++++++ backend/app/services/auth_kakao.py | 342 +++++++++++++++++++++++++++++ backend/requirements.txt | 1 + frontend/src/components/Arena.tsx | 6 + frontend/src/components/Hero.tsx | 61 ++++- frontend/src/lib/auth.ts | 130 +++++++++++ frontend/src/main.tsx | 3 + 11 files changed, 684 insertions(+), 4 deletions(-) create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/services/auth_kakao.py create mode 100644 frontend/src/lib/auth.ts diff --git a/backend/.env.example b/backend/.env.example index cf881e6..e2e50b7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index 71dd242..796d533 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/main.py b/backend/app/main.py index 1e03abe..7a8b1f7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 70018e2..cb5ad61 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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 프롬프트 조립용. diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..f84b20e --- /dev/null +++ b/backend/app/routers/auth.py @@ -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) diff --git a/backend/app/services/auth_kakao.py b/backend/app/services/auth_kakao.py new file mode 100644 index 0000000..81e7682 --- /dev/null +++ b/backend/app/services/auth_kakao.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index e43ee4a..c39ff5e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/components/Arena.tsx b/frontend/src/components/Arena.tsx index a0061d5..c246604 100644 --- a/frontend/src/components/Arena.tsx +++ b/frontend/src/components/Arena.tsx @@ -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("form"); const [recentEmails, setRecentEmails] = useState(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(initialCrowd); const [copied, setCopied] = useState(false); diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx index 5c6a14e..8e2129d 100644 --- a/frontend/src/components/Hero.tsx +++ b/frontend/src/components/Hero.tsx @@ -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 ( + + + {user.profileImageUrl ? ( + + ) : ( + + 👤 + + )} + + {user.nickname || user.email || "회원"} + + + + + ); + } + return ( + + ); +} + type Share = { url: string; title: string; text: string }; // 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출. @@ -31,7 +77,7 @@ export default function Hero({ }; return (
- {back && ( + {back ? (
{t.back} - {share && ( - - )} + + + {share && ( + + )} + +
+ ) : ( +
+
)}
diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..49b414e --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -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 { + 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 { + 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 | null = null; +const listeners = new Set<() => void>(); +const notify = () => listeners.forEach((fn) => fn()); + +export async function getMe(): Promise { + 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 { + const res = await fetch(`${BASE}/auth/kakao/login`); + const d = await res.json(); + window.location.href = d.authUrl; // 카카오 인증 페이지로 이동 +} + +export async function logout(): Promise { + 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(userCache ?? null); + useEffect(() => { + const sync = () => setUser(userCache ?? null); + listeners.add(sync); + getMe().then(sync); + return () => { + listeners.delete(sync); + }; + }, []); + return { user, login: startKakaoLogin, logout }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 375e2c0..e747bca 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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(