Skip to content

[Feat/#5] JWT 기반 인증 필터 구현 - #6

Open
tnals0924 wants to merge 13 commits into
mainfrom
feat/#5-jwt-auth-filter
Open

[Feat/#5] JWT 기반 인증 필터 구현#6
tnals0924 wants to merge 13 commits into
mainfrom
feat/#5-jwt-auth-filter

Conversation

@tnals0924

@tnals0924 tnals0924 commented Aug 18, 2026

Copy link
Copy Markdown
Member

#️⃣연관된 이슈

🎯 해결하려는 문제가 무엇인가요?

gateway:auth 모듈이 build.gradle.kts만 있고 비어 있어, 어떤 API도 "요청을 보낸 사람이 누구인지" 알 수 없는 상태였습니다. core:common에도 인증 추상(Role/CouncilDepartment/PrincipalProvider)이 없어 도메인·API 모듈이 인증 컨텍스트를 참조할 방법이 없었습니다.

이 PR은 Access Token 발급·검증 경로를 세워, 이후 모든 API가 SecurityContextHolder 기반 인증 컨텍스트 위에서 동작할 수 있는 토대를 만듭니다.

❓ 왜 해결해야 하나요?

학생 앱(/v1/app/**)과 운영진 콘솔(/v1/admin/**)은 첫 API부터 인증이 전제입니다. 인증 기반이 없으면 어떤 도메인 기능도 실제로 붙일 수 없어, 다른 모든 작업의 선행 조건입니다.

⭐ 어떻게 해결했나요?

인증 흐름

Authorization: Bearer <token>
    ↓
JwtAuthFilter          — 토큰 파싱 → UserAuthentication을 SecurityContextHolder에 저장
    ↓
SecurityConfig         — URL 패턴별 hasAuthority 인가
    ↓
Controller / UseCase   — SecurityPrincipalProvider로 userId·권한 조회

추가한 것

모듈 클래스 역할
core:common Role, CouncilDepartment, PrincipalProvider 인증 추상 (순수 Java)
gateway:auth JwtProperties, JwtProvider 설정 바인딩 / HS256 발급·파싱
gateway:auth JwtAuthFilter, UserAuthentication, JwtPayload 토큰 검증 및 인증 컨텍스트 주입
gateway:auth SecurityPrincipalProvider PrincipalProvider 구현
gateway:auth SecurityConfig, PublicEndpoints 필터 체인·URL 인가, 공개 엔드포인트
gateway:auth RestAuthenticationEntryPoint, RestAccessDeniedHandler 401·403 공통 에러 응답

토큰 claim

Claim
sub userId
roles ["STUDENT", "ADMIN"] — 학생회 임원은 겸직 가능
council ["WELFARE"]ADMIN을 가진 사용자만 채워짐

authority는 "STUDENT", "ADMIN", "COUNCIL_WELFARE" 형태로 조립됩니다. ROLE_ 접두사가 없으므로 hasRole()이 아니라 hasAuthority()를 씁니다.

설계 결정

  • Java 패키지는 kr.ac.kookmin.stream.securitycore:domain:auth가 쓸 ...stream.auth와 겹치면 Modulith가 두 Gradle 모듈을 한 애플리케이션 모듈로 인식해 경계 검증이 흐려집니다.
  • 학생회 부서 enum은 CouncilDepartmentmember 도메인이 학부 구분(SW/AI)에 Department를 쓰기로 해 어휘를 분리했습니다. 패키지가 달라 컴파일 충돌은 없지만, 한 코드베이스에서 "부서"가 두 의미를 갖는 것을 피했습니다. JWT claim은 council, authority 접두사는 COUNCIL_.
  • JwtAuthFilter를 빈으로 등록하지 않음@Component로 두면 Boot가 서블릿 필터 체인에도 자동 등록해 두 번 실행되고, 첫 실행이 Security 체인 밖이라 인가 순서를 우회합니다. SecurityConfig에서 직접 만들어 addFilterBefore에만 넘깁니다.
  • 필터를 AuthorizationFilter 앞(= ExceptionTranslationFilter 뒤)에 배치 — 아래 "검토한 대안" 참조.

함께 정리한 컨벤션 (리뷰 시 별도로 봐주세요)

  • coding-style.md2-10 객체 생성(정적 팩토리), 2-11 Lombok 절을 추가하고 기존 예시 코드를 새 규칙에 맞췄습니다.
  • 루트 lombok.config 추가 — @Qualifier를 생성자 파라미터로 복사해 스프링 빈에서 @RequiredArgsConstructor를 쓸 수 있게 했습니다.
  • MemberJpaEntity를 정적 팩토리(from)로 바꿨습니다. 새 컨벤션과 어긋난 유일한 기존 코드였습니다.

🧩 이 PR의 한계 & 트레이드오프

  • 테스트가 없습니다. 프로젝트 규칙상 명시 요청 시에만 테스트를 작성해 이번엔 넣지 않았습니다. 컴파일과 ModularityTests.verify(), contextLoads는 통과하지만 이들은 배선만 검증합니다. 필터 순서·예외 전파·인가 결과 같은 런타임 동작은 검증되지 않은 상태입니다. MockMvc로 "토큰 없음 → 401 / 만료 토큰 → 401 / STUDENT가 admin 경로 → 403"을 짚는 테스트를 이어서 붙이는 것을 권합니다.
  • JwtProvider.generateAccessToken(...)은 호출자가 없습니다. 로그인 API와 core:domain:auth 도메인 로직은 범위 밖이라, 발급 기능만 준비된 상태입니다.
  • SecurityPrincipalProvider도 주입받는 쪽이 없습니다. api:* 모듈이 아직 비어 있습니다.
  • 인증 에러를 세분화하지 않았습니다. 만료·서명 불일치·형식 오류를 모두 CommonErrorCode.UNAUTHORIZED 하나로 응답합니다. 클라이언트가 "만료"와 "위조"를 구분해야 하면 별도 ErrorCode가 필요한데, gateway:authcore:domain:auth에 의존할 수 없어 어디에 둘지 결정이 선행돼야 합니다.
  • Refresh Token·로그아웃·CouncilDepartmentAccessChecker는 포함하지 않았습니다.

⛓️ 기존 기능에 미치는 영향

  • 기존 기능이 거의 없어 파급은 제한적입니다. member 도메인과 infrastructure:db는 그대로 동작합니다.
  • MemberJpaEntity 생성 방식이 바뀝니다. new MemberJpaEntity(member)MemberJpaEntity.from(member). 현재 호출부가 없어 깨지는 곳은 없습니다.
  • bootstrapJWT_SECRET_KEY, JWT_ISSUER 환경변수를 요구합니다. 루트 .env.example에 항목을 추가했고, 테스트는 bootstrap/src/test/resources/application-gateway-auth.ymlgateway:auth의 동명 파일을 가려 더미 값으로 뜹니다.
  • 의존 방향은 gateway:auth → core:common 단방향만 추가돼 architecture.md 3절을 따릅니다. core:common은 여전히 순수 Java입니다.

🔀 Edge Case & 실패 시나리오

상황 동작
Authorization 헤더 없음 인증 세팅 없이 통과. 보호 경로면 AuthorizationFilter가 막고 EntryPoint가 401
Bearer 접두사 아님 위와 동일하게 무시
만료·서명 불일치·형식 오류 InvalidTokenExceptionExceptionTranslationFilter → EntryPoint → 401
sub가 숫자가 아님 / 모르는 enum 이름 IllegalArgumentExceptionInvalidTokenException으로 감싸 401
iss 불일치 requireIssuer로 검증 실패 → 401
인증됐으나 권한 없음 AccessDeniedExceptionRestAccessDeniedHandler → 403
JWT_SECRET_KEY가 32바이트 미만 JJWT Keys.hmacShaKeyFor가 기동 시점에 실패 (조용히 넘어가지 않음)

📋 검토한 대안과 선택 이유

1. 인증 실패 응답 경로 — HandlerExceptionResolver 위임 vs EntryPoint 직접 작성

필터에서 발생한 예외는 @RestControllerAdvice가 잡지 못합니다. EntryPoint에서 JSON을 직접 쓰면 당장은 되지만, ApiResponseapi:common-api에 있어 gateway:auth가 응답 규격을 중복 정의하게 됩니다(의존 방향상 참조 불가). HandlerExceptionResolver에 위임해 GlobalExceptionHandler가 처리하도록 했습니다 — architecture.md 7절의 gateway:auth → spring-webmvc(예외 위임)와 일치합니다.

2. 필터 위치 — UsernamePasswordAuthenticationFilter 앞 vs AuthorizationFilter

관례적인 UsernamePasswordAuthenticationFilter 앞은 ExceptionTranslationFilter보다 이라, 필터가 던진 예외를 Security가 잡을 수 없습니다. 그래서 처음엔 필터가 HandlerExceptionResolver를 직접 들고 있었는데, 인증 실패 처리가 필터와 SecurityConfig 두 곳으로 갈렸습니다. 필터를 AuthorizationFilter 앞으로 옮겨 InvalidTokenException(= AuthenticationException)이 ExceptionTranslationFilter를 통해 EntryPoint로 흐르게 했습니다. 결과적으로 필터에서 try/catchHandlerExceptionResolver 의존이 사라지고, 인증 실패 응답을 만드는 곳이 두 핸들러로 단일화됐습니다.

3. SecurityConfig 위치 — gateway:auth vs api:common-api

config-and-auth.md 4-5절은 gateway:auth, architecture.md 2-2절은 api:common-api로 적고 있어 문서 간 불일치가 있습니다. 필터 등록이 자연스럽고 gateway:auth만으로 인증이 자립하는 쪽을 택했습니다. 어느 한쪽 문서를 고치는 후속 작업이 필요합니다.

4. PublicEndpoints — 상수 배열 vs enum

그룹 이름을 상수로 드러내고(SWAGGER, HEALTH_CHECK) 그룹 단위 접근을 열어두기 위해 enum으로 했습니다. isPublic(path)/swagger-ui/** 같은 와일드카드 때문에 문자열 비교가 아니라 PathPattern 매칭이며, 패턴은 enum 생성 시점에 미리 파싱해 재사용합니다.

💬 리뷰 포인트

  • [r] CouncilDepartment 부서 목록 — 회장단·집행부·총무부·기획부·홍보부·미디어부·복지부·소통부 8개가 맞는지. EXECUTIVE(집행부)와 PRESIDENCY(회장단)는 영문으로 헷갈리기 쉬워 한글 주석을 달았습니다.
  • [c] JwtProvider만 명시적 생성자 — 주입값으로 SecretKey를 파생시켜 @RequiredArgsConstructor를 쓰지 않았습니다. 컨벤션 2-11절에 이 예외를 명시했습니다.
  • [a] Rest- 접두사 (RestAuthenticationEntryPoint/RestAccessDeniedHandler) — 기본 구현이 로그인 페이지로 리다이렉트하는 것과 대비해 붙였습니다. Jwt- 접두사가 흔하지만 실제로 JWT와 무관한 동작이라 피했습니다.
  • [a] isPublic은 아직 호출부가 없습니다. 로깅 필터에서 헬스체크 로그를 거르는 용도 등을 염두에 뒀습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JWT 기반 인증 필터 구현

1 participant