Skip to content

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

Merged
tnals0924 merged 19 commits into
mainfrom
feat/#5-jwt-auth-filter
Aug 24, 2026
Merged

[Feat/#5] JWT 기반 인증 필터 구현#6
tnals0924 merged 19 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은 아직 호출부가 없습니다. 로깅 필터에서 헬스체크 로그를 거르는 용도 등을 염두에 뒀습니다.

@sangrae2325

Copy link
Copy Markdown
Collaborator

[r] 실제 부서목록 8개와 일치합니다.
[c] secretKey같이 내부에서 파생되는 값의 경우를 예외 케이스로 정의한것으로 이해했습니다 !
[a] 실제 동작에 기반한 네이밍 방식 좋습니다 !
[a] 로깅 필터를 통해 공개 엔드포인트 로그를 제외시키는 용도로 이해하면 될까요?

@jjunh33

jjunh33 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator
  • [r] CouncilDepartment
    • 현재 정의된 회장단, 집행부, 총무부, 기획부, 홍보부, 미디어부, 복지부, 소통부 8개 구성 확인했습니다.
    • PRESIDENCY, EXECUTIVE도 한글 주석이 있어 이해하기 쉬울 것 같습니다.
  • [c] JwtProvider 생성자
    • SecretKey를 생성자에서 파생해서 초기화해야 하는 구조이고, 컨벤션에도 예외로 명시되어 있어 현재 방식에 동의합니다!
  • [a] Rest- 접두사
    • REST API의 응답 처리 역할에 가깝다고 생각해서 Rest- 접두사 사용에 동의합니다.
  • [a] PublicEndpoints.isPublic()
    • 현재 호출부가 없고, 추후 로깅 필터 등에서 사용할 목적인 점 확인했습니다.

추가로 SecurityConfig 위치 기준이 서로 다른 부분은 후속 작업에서 하나로 통일하는 것으로 확인했습니다!

@xeoxxn

xeoxxn commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

코드 부분 확인 완료했습니다!
검토한 대안과 선택 이유 3번 부분 architecture.md 부분 수정하면 될 것 같고,
부서 구성 및 주석 확인 완료했습니다!

또한 isPublic이 현재 호출부가 없다고 나와있는데, 이후 실제로 연결할 때 SecurityConfig와 isPublic이 판단하는 경로가 일치하는지 개발할 때 한번씩 확인하면 좋을 것 같습니다!

Comment on lines +42 to +47
public static boolean isPublic(String path) {
PathContainer pathContainer = PathContainer.parsePath(path);
return Arrays.stream(values())
.flatMap(endpoints -> endpoints.pathPatterns.stream())
.anyMatch(pathPattern -> pathPattern.matches(pathContainer));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PathPattern을 미리 파싱해 두신 부분 좋습니다! 
현재 isPublic 호출 시 매번 flatMap으로 모든 주소 목록을 새로 합쳐서 검사하는데, 서버로 요청이 몰리면 매번 목록을 합치느라 성능에 부하가 좀 생길 수도 있을 것 같아 나중에 전체 주소 패턴을 미리 하나의 static 리스트로 다 합쳐두고 가져다 쓰는 방식으로 개선해보아도 좋을 것 같습니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분같은 경우 성능은 무시 가능한 수준입니다.

isPublic()에서 실제 비용은 PathPattern.matches() 4회고, flatMap이 얹는 건 values() 방어적 복사와 Stream 객체 할당 정도라 요청 1건 처리 시간에 비하면 무시 가능한 수준입니다.

대신 요청마다 매번 리스트를 합치는 게 코드 해석 의미상 자연스럽다고 느껴지진 않아서 말씀하신 것처럼 리스트 따로 분리해서 반영하도록 하겠습니다~

@leegain1

Copy link
Copy Markdown
Collaborator

코드와 컨벤션 문서 모두 잘 확인했습니다!
부서 구성 확인했고, 예외적으로 명시적 생성자를 쓴 이유도 이해했고, lombok.config 설정까지 컨벤션대로 깔끔히 적용된 점 확인했습니다!
Rest~ 네이밍도 기술명인 JWT대신 실제 역할에 맞게 지은점이 좋은것 같고, isPublic 설계 부분도 확인완료했습니다
SecurityConfig 위치를 gateway:auth로 일원화해 자립성을 높인 결정에 동의해서, architecture.md 문서에 불일치한 부분 수정하면 될 것 같습니다!

@leegain1
leegain1 self-requested a review August 23, 2026 13:33
@tnals0924

tnals0924 commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

[r] 실제 부서목록 8개와 일치합니다. [c] secretKey같이 내부에서 파생되는 값의 경우를 예외 케이스로 정의한것으로 이해했습니다 ! [a] 실제 동작에 기반한 네이밍 방식 좋습니다 ! [a] 로깅 필터를 통해 공개 엔드포인트 로그를 제외시키는 용도로 이해하면 될까요?

@sangrae2325 맞습니다. 로깅 등 필터에서 직접 공개 api path를 제외해줘야 하는 경우가 생겨서 그렇습니다!

@tnals0924
tnals0924 force-pushed the feat/#5-jwt-auth-filter branch from fda6317 to b188cf3 Compare August 24, 2026 04:39
[Feat/#7] 공통 API 응답 포맷 추가
@tnals0924
tnals0924 merged commit 221f539 into main Aug 24, 2026
@tnals0924
tnals0924 deleted the feat/#5-jwt-auth-filter branch August 24, 2026 04:46
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 기반 인증 필터 구현

5 participants