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:
jwkim 2026-08-14 15:42:54 +09:00
parent e2d1647100
commit 7705099c64
11 changed files with 684 additions and 4 deletions

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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 프롬프트 조립용.

View 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)

View 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

View File

@ -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

View File

@ -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);

View File

@ -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
View 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 };
}

View File

@ -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>