o2o-castad-backend/app/user/services/auth.py

543 lines
18 KiB
Python

"""
인증 서비스
카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다.
"""
import logging
from typing import Optional
from fastapi import HTTPException, status
from app.utils.timezone import now
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from config import prj_settings
logger = logging.getLogger(__name__)
# =============================================================================
# 인증 예외 클래스 정의
# =============================================================================
class AuthException(HTTPException):
"""인증 관련 기본 예외"""
def __init__(self, status_code: int, code: str, message: str):
super().__init__(status_code=status_code, detail={"code": code, "message": message})
class TokenExpiredError(AuthException):
"""토큰 만료"""
def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."):
super().__init__(status.HTTP_401_UNAUTHORIZED, "TOKEN_EXPIRED", message)
class InvalidTokenError(AuthException):
"""유효하지 않은 토큰"""
def __init__(self, message: str = "유효하지 않은 토큰입니다."):
super().__init__(status.HTTP_401_UNAUTHORIZED, "INVALID_TOKEN", message)
class TokenRevokedError(AuthException):
"""취소된 토큰"""
def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."):
super().__init__(status.HTTP_401_UNAUTHORIZED, "TOKEN_REVOKED", message)
class MissingTokenError(AuthException):
"""토큰 누락"""
def __init__(self, message: str = "인증 토큰이 필요합니다."):
super().__init__(status.HTTP_401_UNAUTHORIZED, "MISSING_TOKEN", message)
class UserNotFoundError(AuthException):
"""사용자 없음"""
def __init__(self, message: str = "가입되지 않은 사용자 입니다."):
super().__init__(status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", message)
class UserInactiveError(AuthException):
"""비활성화된 계정"""
def __init__(self, message: str = "활성화 상태가 아닌 사용자 입니다."):
super().__init__(status.HTTP_403_FORBIDDEN, "USER_INACTIVE", message)
class AdminRequiredError(AuthException):
"""관리자 권한 필요"""
def __init__(self, message: str = "관리자 권한이 필요합니다."):
super().__init__(status.HTTP_403_FORBIDDEN, "ADMIN_REQUIRED", message)
from app.user.models import RefreshToken, User
from app.utils.common import generate_uuid
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoUserInfo,
LoginResponse,
TokenResponse,
)
from app.user.services.jwt import (
create_access_token,
create_refresh_token,
decode_token,
get_access_token_expire_seconds,
get_refresh_token_expires_at,
get_token_hash,
)
from app.user.services.kakao import kakao_client
class AuthService:
"""
인증 서비스
카카오 로그인, JWT 토큰 발급/갱신/폐기, 사용자 관리 기능을 제공합니다.
"""
async def kakao_login(
self,
code: str,
session: AsyncSession,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> LoginResponse:
"""
카카오 로그인 처리
1. 카카오 인가 코드로 액세스 토큰 획득
2. 카카오 사용자 정보 조회
3. 사용자 조회 또는 생성
4. JWT 토큰 발급 및 리프레시 토큰 저장
Args:
code: 카카오 인가 코드
session: DB 세션
user_agent: 클라이언트 User-Agent
ip_address: 클라이언트 IP 주소
Returns:
LoginResponse: 토큰 및 사용자 정보
"""
logger.info(f"[AUTH] 카카오 로그인 시작 - code: {code[:20]}..., ip: {ip_address}")
# 1. 카카오 토큰 획득
logger.info("[AUTH] 1단계: 카카오 토큰 획득 시작")
kakao_token = await kakao_client.get_access_token(code)
logger.debug(f"[AUTH] 카카오 토큰 획득 완료 - token_type: {kakao_token.token_type}")
# 2. 카카오 사용자 정보 조회
logger.info("[AUTH] 2단계: 카카오 사용자 정보 조회 시작")
kakao_user_info = await kakao_client.get_user_info(kakao_token.access_token)
logger.debug(f"[AUTH] 카카오 사용자 정보 조회 완료 - kakao_id: {kakao_user_info.id}")
# 3. 사용자 조회 또는 생성
logger.info("[AUTH] 3단계: 사용자 조회/생성 시작")
user, is_new_user = await self._get_or_create_user(kakao_user_info, session)
logger.info(f"[AUTH] 사용자 처리 완료 - user_id: {user.id}, is_new_user: {is_new_user}")
# 4. 비활성화 계정 체크
if not user.is_active:
logger.error(f"[AUTH] 비활성화 계정 접근 시도 - user_id: {user.id}")
raise UserInactiveError()
# 5. JWT 토큰 생성
logger.info("[AUTH] 5단계: JWT 토큰 생성 시작")
access_token = create_access_token(user.user_uuid)
refresh_token = create_refresh_token(user.user_uuid)
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_uuid: {user.user_uuid}")
# 6. 리프레시 토큰 DB 저장
logger.info("[AUTH] 6단계: 리프레시 토큰 저장 시작")
await self._save_refresh_token(
user_id=user.id,
user_uuid=user.user_uuid,
token=refresh_token,
session=session,
user_agent=user_agent,
ip_address=ip_address,
)
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
# 7. 마지막 로그인 시간 업데이트
user.last_login_at = now().replace(tzinfo=None)
await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}, redirect_url: {redirect_url}")
logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...")
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="Bearer",
expires_in=get_access_token_expire_seconds(),
is_new_user=is_new_user,
redirect_url=redirect_url,
)
async def refresh_tokens(
self,
refresh_token: str,
session: AsyncSession,
) -> TokenResponse:
"""
리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation)
기존 리프레시 토큰을 폐기하고, 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다.
사용자가 서비스를 지속 사용하는 한 세션이 자동 유지됩니다.
Args:
refresh_token: 리프레시 토큰
session: DB 세션
Returns:
TokenResponse: 새 액세스 토큰 + 새 리프레시 토큰
Raises:
InvalidTokenError: 토큰이 유효하지 않은 경우
TokenExpiredError: 토큰이 만료된 경우
TokenRevokedError: 토큰이 폐기된 경우
"""
logger.info("[AUTH] 토큰 갱신 시작 (Refresh Token Rotation)")
# 1. 토큰 디코딩 및 검증
payload = decode_token(refresh_token)
if payload is None:
raise InvalidTokenError()
if payload.get("type") != "refresh":
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
# 2. DB에서 리프레시 토큰 조회
token_hash = get_token_hash(refresh_token)
db_token = await self._get_refresh_token_by_hash(token_hash, session)
if db_token is None:
raise InvalidTokenError()
# 3. 토큰 상태 확인
if db_token.is_revoked:
raise TokenRevokedError()
if db_token.expires_at < now().replace(tzinfo=None):
raise TokenExpiredError()
# 4. 사용자 확인
user_uuid = payload.get("sub")
user = await self._get_user_by_uuid(user_uuid, session)
if user is None:
raise UserNotFoundError()
if not user.is_active:
raise UserInactiveError()
# 5. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음)
db_token.is_revoked = True
db_token.revoked_at = now().replace(tzinfo=None)
# 6. 새 토큰 발급
new_access_token = create_access_token(user.user_uuid)
new_refresh_token = create_refresh_token(user.user_uuid)
# 7. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행)
await self._save_refresh_token(
user_id=user.id,
user_uuid=user.user_uuid,
token=new_refresh_token,
session=session,
)
# 8. 폐기 + 저장을 하나의 트랜잭션으로 커밋
await session.commit()
logger.info(f"[AUTH] 토큰 갱신 완료 - user_uuid: {user.user_uuid}")
return TokenResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="Bearer",
expires_in=get_access_token_expire_seconds(),
)
async def logout(
self,
user_id: int,
refresh_token: str,
session: AsyncSession,
) -> None:
"""
로그아웃 (리프레시 토큰 폐기)
Args:
user_id: 사용자 ID
refresh_token: 폐기할 리프레시 토큰
session: DB 세션
"""
token_hash = get_token_hash(refresh_token)
await self._revoke_refresh_token_by_hash(token_hash, session)
async def logout_all(
self,
user_id: int,
session: AsyncSession,
) -> None:
"""
모든 기기에서 로그아웃 (사용자의 모든 리프레시 토큰 폐기)
Args:
user_id: 사용자 ID
session: DB 세션
"""
await self._revoke_all_user_tokens(user_id, session)
async def _get_or_create_user(
self,
kakao_user_info: KakaoUserInfo,
session: AsyncSession,
) -> tuple[User, bool]:
"""
사용자 조회 또는 생성
Args:
kakao_user_info: 카카오 사용자 정보
session: DB 세션
Returns:
(User, is_new_user) 튜플
"""
kakao_id = kakao_user_info.id
kakao_account = kakao_user_info.kakao_account
profile = kakao_account.profile if kakao_account else None
logger.info(f"[AUTH] 사용자 조회 시작 - kakao_id: {kakao_id}")
# 기존 사용자 조회
result = await session.execute(
select(User).where(User.kakao_id == kakao_id)
)
user = result.scalar_one_or_none()
logger.info(f"[AUTH] DB 조회 결과 - user_exists: {user is not None}, user_id: {user.id if user else None}")
if user is not None:
# 기존 사용자: 프로필 정보 업데이트
logger.info(f"[AUTH] 기존 사용자 발견 - user_id: {user.id}, is_new_user: False")
if profile:
user.nickname = profile.nickname
user.profile_image_url = profile.profile_image_url
user.thumbnail_image_url = profile.thumbnail_image_url
if kakao_account and kakao_account.email:
user.email = kakao_account.email
await session.flush()
return user, False
# 신규 사용자 생성
logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}")
user_uuid = await generate_uuid(session=session, table_name=User)
new_user = User(
kakao_id=kakao_id,
user_uuid=user_uuid,
email=kakao_account.email if kakao_account else None,
nickname=profile.nickname if profile else None,
profile_image_url=profile.profile_image_url if profile else None,
thumbnail_image_url=profile.thumbnail_image_url if profile else None,
)
session.add(new_user)
try:
await session.flush()
await session.refresh(new_user)
logger.info(f"[AUTH] 신규 사용자 생성 완료 - user_id: {new_user.id}, is_new_user: True")
return new_user, True
except IntegrityError:
# 동시 요청으로 인한 중복 삽입 시도 - 기존 사용자 조회
logger.warning(
f"[AUTH] IntegrityError 발생 (동시 요청 추정) - kakao_id: {kakao_id}, "
"기존 사용자 재조회 시도"
)
await session.rollback()
result = await session.execute(
select(User).where(User.kakao_id == kakao_id)
)
existing_user = result.scalar_one_or_none()
if existing_user is not None:
logger.info(
f"[AUTH] 기존 사용자 재조회 성공 - user_id: {existing_user.id}, "
"is_new_user: False"
)
# 프로필 정보 업데이트
if profile:
existing_user.nickname = profile.nickname
existing_user.profile_image_url = profile.profile_image_url
existing_user.thumbnail_image_url = profile.thumbnail_image_url
if kakao_account and kakao_account.email:
existing_user.email = kakao_account.email
await session.flush()
return existing_user, False
# 재조회에도 실패한 경우 (매우 드문 경우)
logger.error(
f"[AUTH] IntegrityError 후 재조회 실패 - kakao_id: {kakao_id}"
)
raise
async def _save_refresh_token(
self,
user_id: int,
user_uuid: str,
token: str,
session: AsyncSession,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> RefreshToken:
"""
리프레시 토큰 DB 저장
Args:
user_id: 사용자 ID
user_uuid: 사용자 UUID
token: 리프레시 토큰
session: DB 세션
user_agent: User-Agent
ip_address: IP 주소
Returns:
저장된 RefreshToken 객체
"""
token_hash = get_token_hash(token)
expires_at = get_refresh_token_expires_at()
refresh_token = RefreshToken(
user_id=user_id,
user_uuid=user_uuid,
token_hash=token_hash,
expires_at=expires_at,
user_agent=user_agent,
ip_address=ip_address,
)
session.add(refresh_token)
await session.flush()
return refresh_token
async def _get_refresh_token_by_hash(
self,
token_hash: str,
session: AsyncSession,
) -> Optional[RefreshToken]:
"""
해시값으로 리프레시 토큰 조회
Args:
token_hash: 토큰 해시값
session: DB 세션
Returns:
RefreshToken 객체 또는 None
"""
result = await session.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
return result.scalar_one_or_none()
async def _get_user_by_id(
self,
user_id: int,
session: AsyncSession,
) -> Optional[User]:
"""
ID로 사용자 조회
Args:
user_id: 사용자 ID
session: DB 세션
Returns:
User 객체 또는 None
"""
result = await session.execute(
select(User).where(User.id == user_id, User.is_deleted == False) # noqa: E712
)
return result.scalar_one_or_none()
async def _get_user_by_uuid(
self,
user_uuid: str,
session: AsyncSession,
) -> Optional[User]:
"""
UUID로 사용자 조회
Args:
user_uuid: 사용자 UUID
session: DB 세션
Returns:
User 객체 또는 None
"""
result = await session.execute(
select(User).where(User.user_uuid == user_uuid, User.is_deleted == False) # noqa: E712
)
return result.scalar_one_or_none()
async def _revoke_refresh_token_by_hash(
self,
token_hash: str,
session: AsyncSession,
) -> None:
"""
해시값으로 리프레시 토큰 폐기
Args:
token_hash: 토큰 해시값
session: DB 세션
"""
await session.execute(
update(RefreshToken)
.where(RefreshToken.token_hash == token_hash)
.values(
is_revoked=True,
revoked_at=now().replace(tzinfo=None),
)
)
await session.commit()
async def _revoke_all_user_tokens(
self,
user_id: int,
session: AsyncSession,
) -> None:
"""
사용자의 모든 리프레시 토큰 폐기
Args:
user_id: 사용자 ID
session: DB 세션
"""
await session.execute(
update(RefreshToken)
.where(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked == False, # noqa: E712
)
.values(
is_revoked=True,
revoked_at=now().replace(tzinfo=None),
)
)
await session.commit()
auth_service = AuthService()