[BE] 인증 인가 설계 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki

배경 (Background)

프로젝트 목표

  • Devths의 인증/인가 아키텍처 및 플로우를 구축, 사용자가 서비스를 정상적으로 사용할 수 있는 기반을 제공한다.
  • 로그인/회원가입/토큰 재발급/로그아웃이 안정적으로 동작한다.
  • 인증이 필요한 API는 인증되지 않은 요청을 차단하고(401), 권한이 없는 요청을 차단한다(403).
  • AT 만료 시에도 RT 기반으로 사용자 경험이 끊기지 않는다.
  • XSS/CSRF 등 웹 보안 위협을 고려한 토큰 저장/전송 전략을 적용한다.
  • RT Rotation으로 RT 탈취 재사용을 탐지/차단한다.

문제 정의

  • Devths는 Google OAuth2를 통해 외부 사용자 신원을 확인하지만, 서비스 고유의 사용자 정보와 JWT 기반 AT/RT 체계를 별도로 운용한다. 따라서 외부 식별자(provider_user_id=sub)와 내부 사용자(userId)의 매핑 정합성, 신규 유저 온보딩(tempToken), 토큰 저장/전송 전략(CORS, CSRF), 그리고 Google 리소스 접근을 위한 Google AT/RT 안전 저장 및 갱신 정책을 함께 설계해야 한다.

목표가 아닌 것 (Non-goals) (Optional)

  • 해당 문서에서는 Devths의 인증 및 인가 아키텍처에 대한 내용만 다룬다.
    • 회원 관련 ERD, API 명세에 관한 내용은 본 서비스의 ‘유저’ 도메인 테크 스펙에 포함한다.

설계 및 기술 자료 (Architecture and Technical Documentation)

인증/인가 설계

사용자 식별 방식

  • 외/내부 식별자
    • 외부(OAuth Provider) : Google
      • 식별 키 : provider_user_id (id_token의 subject)
      • 서명/iss/aud/exp/iat 검증 후 sub 사용
    • 내부(Devths)
      • 식별 키 : userId (users 테이블의 PK)
  • 토큰 기반 식별(JWT)
    • JWT 채택 이유
      • 서버가 사용자의 상태를 기억하지 않아도 되므로 RESTful API 기반의 Stateless 아키텍처에 적합
      • 추후 다중 인스턴스 환경으로 확장 시 Scale-Out이 용이(세션 동기화 불필요)
    • Access Token(AT): 요청 당 사용자 식별용(TTL 30분)
      • 클레임
        • sub: 내부 식별자(userId)
        • roles: [ROLE_USER, ...]
        • iat, exp
    • Refresh Token(RT): AT 재발급용(TTL 14일), Refresh Token Rotation 정책 적용
    • tempToken: 회원가입 전 신규 사용자를 식별하기 위한 초단기 임시 토큰(TTL 10분)
      • 서비스 계정 생성 전 단계이므로 권한 범위를 POST /api/users (회원가입)으로 최소화
      • 회원가입 성공 시 tempToken 폐기(재사용 불가)

사용자 역할 및 권한

  • 역할(Role)
    • ROLE_USER: 기본 사용자
    • ROLE_ADMIN: 운영자 및 시스템 관리자
      • 알림 발송 요청 등 시스템 관련 기능에 접근할 수 있음
  • 권한(Authorization) 정책
    • 인증 필요 엔드 포인트 : 기본적으로 /api/**는 모두 인증이 필요하나, 아래는 예외
      • POST /api/auth/google: 소셜 로그인
      • POST /api/auth/tokens: 액세스 토큰 재발급(단, 유효한 RT 필요)
      • POST /api/users: 회원가입(단, 유효한 tempToken 필요)

보안성 확보를 위한 기타 설정

  • Refresh Token 저장 위치 및 상태 관리 방식
    • 서버 응답(회원가입, 로그인, 토큰 재발급) 시 Set-Cookie로 내려주고, 필요 시 Response Cookie에 담아 전송
    • 서버 DB에 사용자 별 RT를 저장하여 상태 관리
      • 초기 RDB 테이블, 아키텍처 고도화 시 Redis
    • Cookie 설정
      • HttpOnly: XSS 공격 방지
      • Secure: HTTPS에서만 전송
      • SameSite=Lax: CSRF 완화(단, 크로스사이트 사용 시 조정 필요)
      • Path=/api/auth: RT가 필요한 엔드포인트 범위로만 전송(노출 최소화)
      • Max-Age/Expires: RT의 TTL과 일치
    • 다중 디바이스 미허용
    • 로그아웃 및 탈퇴 시 처리
      • 서버 저장소의 RT 폐기 및 현재 쿠키 만료(Set-Cookie Max-Age=0)
  • Access Token 저장 위치 및 전달 방식
    • 클라이언트 저장 방식 명시
      • 프론트는 AT를 메모리(in-memory)에 저장하고, 토큰 만료 시 또는 새로고침 시 /api/auth/tokens로 재발급
    • 헤더 표준
      • 요청: Authorization: Bearer {accessToken}
      • 응답: Authorization: Bearer {accessToken}
      • 브라우저에서 읽기 위해 Access-Control-Expose-Headers: Authorization
    • 서버에는 별도로 저장하지 않음
    • TTL 정책
      • 만료 시간을 30분으로 두고 RTR 기반 재발급
  • RTR(Refresh Token Rotation) 정책
    • /api/auth/tokens 성공 시: 새 RT 발급 + 기존 RT 폐기
      • 새 RT는 DB 저장소에도 원자적으로 업데이트
    • RT 재사용 탐지 시 대응 정책
      • 이미 폐기된 RT가 다시 오면 탈취 가능성 존재로 판단
      • 해당 사용자(또는 해당 디바이스)의 모든 RT 폐기 + 강제 재로그인으로 대응
    • 동시성 제어
      • 재발급 API가 동시에 두 번 호출되면 레이스 발생 가능
      • user_id 단위 락
  • Google AT/RT 저장 여부
    • 사용자 구글 리소스(Google Calendar API, Google Tasks API)에 접근해야 하므로 Google AT/RT를 서버 DB에 저장
    • 저장 목적/범위
      • 서버가 사용자 대신 Google Calendar/Tasks API 호출
      • 캘린더 CRUD 및 To-do CRUD 기능에 사용
    • AT 저장 정책 재검토
      • AT는 초기 RDB에 저장, 추후 Redis로 이관
    • 토큰 갱신 동시성
      • Google 토큰 갱신도 동시 요청 경쟁 가능 → provider_user_id 단위 락
    • 권한 철회(Revoked) 대응
      • Google에서 권한 철회/연동 해제 시 invalid_grant 발생
      • 이 경우 재시도 멈추고 status=REVOKED, 사용자에게 “재연동 필요” 안내
  • HTTPS
    • 전 구간 HTTPS 강제
      • RT가 Secure 쿠키로 전달됨
  • CSRF 방어
    • 위협 모델
      • AT는 헤더로 보내므로 CSRF 영향 적음(공격자가 헤더를 임의로 못 붙임)
      • 그러나 RT는 쿠키이므로 브라우저가 자동 전송하므로 /api/auth/tokens, /api/auth/logout이 CSRF 대상이 될 수 있음
    • 1차 방어: SameSite
      • SameSite=Lax로 대부분의 Cross-Site POST를 줄임
      • 단, 프론트엔드/백엔드가 서로 다른 Site라면 조정 필요
    • 2차 방어 : CSRF 토큰
      • 재발급/로그아웃 같은 RT 쿠키 기반 엔드포인트에만 적용 고려

인증 프로세스

참여자 및 책임

  • User: Google 로그인 버튼을 통해 소셜 로그인을 시작
  • Frontend: Google 인증 UI 처리 및 authCode를 수신 후 Devths 로그인 API 호출
  • Devths API Server: authCode를 Google에 교환하여 사용자를 식별하고, Devths 토큰(AT/RT)을 발급
  • Google OAuth2 Server: authCode 발급 및 토큰 교환(Token Exchange) 제공
  • Devths DB: 사용자/소셜 계정 매핑 조회 및 저장
  • Token Store: Devths Refresh Token 상태 저장소(RDB→확장 시 Redis)

사전 조건

  • 프론트는 Google OAuth2 Authorization Code Flow로 로그인
  • 백엔드는 Google Token Exchange 후 id_token 검증을 통해 provider_user_id = google_sub을 신뢰
  • provider_user_id(provider, provider_user_id)로 유니크 보장

인증 프로세스 공통 구간: authCode 발급 및 서버 전달 (1~8)

  1. 사용자가 프론트에서 “Google 로그인” 버튼 클릭
  2. 프론트 → Google: Authorization Request
  3. Google → 프론트: authCode (redirect)
  4. 프론트 → Devths: POST /api/auth/google { authCode }
  5. Devths → Google: Token Exchange (authCode)
  6. Google → Devths: id_token + google AT/RT 반환
  7. Devths: id_token 검증 후 provider_user_id(=google_sub) 추출
    • 검증 항목: 서명, iss, aud, exp, iat
  8. Devths → DB: SELECT user WHERE provider_user_id = ?

인증 프로세스 분기 A. 기존 회원인 경우 (9~12)

  1. DB → Devths: user 존재
  2. Devths → Token Store: 서비스 RT 발급 및 저장 (RTR 전제)
  3. Devths → DB: Google AT/RT 저장
  • 저장 목적: Calendar/Tasks API 등 Google 리소스 접근
  1. Devths → 프론트: 로그인 성공 응답
    • Authorization: Bearer {serviceAT}
    • Set-Cookie: refreshToken={serviceRT}; HttpOnly; Secure; SameSite=Lax; Path=/api/auth
    • Body: { isRegistered: true, profile... }

인증 프로세스 분기 B. 신규 회원인 경우 (13~24)

  1. DB → Devths: user 없음
  2. Devths: tempToken 발급(짧은 TTL)
    • 토큰 정책:
      • TTL 10분
      • 1회성: 회원가입 성공 시 즉시 폐기
  3. Devths → 프론트: 회원가입 필요 응답
    • Body: { isRegistered: false, email, tempToken }
  4. 사용자가 프론트에서 회원가입 정보 입력(닉네임/관심사 등)
  5. 프론트 → Devths: POST /api/users { email, nickname, interests, tempToken }
  6. Devths: tempToken 검증(만료/위변조/재사용/바인딩 일치 여부)
  7. Devths → DB: email/nickname 중복 체크
  8. DB → Devths: OK
  9. Devths → DB: 사용자 생성 및 provider_user_id 연결
  10. Devths → Token Store: 서비스 RT 발급 및 저장 (RTR 전제)
  11. Devths → DB: Google AT/RT 저장(기존 회원 케이스와 동일한 보안 정책 적용)
  12. Devths → 프론트: 회원가입 + 자동 로그인 성공
    • Authorization: Bearer {serviceAT}
    • Set-Cookie: refreshToken={serviceRT}; ...
    • Body: { isRegistered: true, profile... }