[Feat/#5] JWT 기반 인증 필터 구현 - #6
Open
tnals0924 wants to merge 13 commits into
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#️⃣연관된 이슈
🎯 해결하려는 문제가 무엇인가요?
gateway:auth모듈이build.gradle.kts만 있고 비어 있어, 어떤 API도 "요청을 보낸 사람이 누구인지" 알 수 없는 상태였습니다.core:common에도 인증 추상(Role/CouncilDepartment/PrincipalProvider)이 없어 도메인·API 모듈이 인증 컨텍스트를 참조할 방법이 없었습니다.이 PR은 Access Token 발급·검증 경로를 세워, 이후 모든 API가
SecurityContextHolder기반 인증 컨텍스트 위에서 동작할 수 있는 토대를 만듭니다.❓ 왜 해결해야 하나요?
학생 앱(
/v1/app/**)과 운영진 콘솔(/v1/admin/**)은 첫 API부터 인증이 전제입니다. 인증 기반이 없으면 어떤 도메인 기능도 실제로 붙일 수 없어, 다른 모든 작업의 선행 조건입니다.⭐ 어떻게 해결했나요?
인증 흐름
추가한 것
core:commonRole,CouncilDepartment,PrincipalProvidergateway:authJwtProperties,JwtProvidergateway:authJwtAuthFilter,UserAuthentication,JwtPayloadgateway:authSecurityPrincipalProviderPrincipalProvider구현gateway:authSecurityConfig,PublicEndpointsgateway:authRestAuthenticationEntryPoint,RestAccessDeniedHandler토큰 claim
subroles["STUDENT", "ADMIN"]— 학생회 임원은 겸직 가능council["WELFARE"]—ADMIN을 가진 사용자만 채워짐authority는
"STUDENT","ADMIN","COUNCIL_WELFARE"형태로 조립됩니다.ROLE_접두사가 없으므로hasRole()이 아니라hasAuthority()를 씁니다.설계 결정
kr.ac.kookmin.stream.security—core:domain:auth가 쓸...stream.auth와 겹치면 Modulith가 두 Gradle 모듈을 한 애플리케이션 모듈로 인식해 경계 검증이 흐려집니다.CouncilDepartment—member도메인이 학부 구분(SW/AI)에Department를 쓰기로 해 어휘를 분리했습니다. 패키지가 달라 컴파일 충돌은 없지만, 한 코드베이스에서 "부서"가 두 의미를 갖는 것을 피했습니다. JWT claim은council, authority 접두사는COUNCIL_.JwtAuthFilter를 빈으로 등록하지 않음 —@Component로 두면 Boot가 서블릿 필터 체인에도 자동 등록해 두 번 실행되고, 첫 실행이 Security 체인 밖이라 인가 순서를 우회합니다.SecurityConfig에서 직접 만들어addFilterBefore에만 넘깁니다.AuthorizationFilter앞(=ExceptionTranslationFilter뒤)에 배치 — 아래 "검토한 대안" 참조.함께 정리한 컨벤션 (리뷰 시 별도로 봐주세요)
coding-style.md에 2-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:auth는core:domain:auth에 의존할 수 없어 어디에 둘지 결정이 선행돼야 합니다.CouncilDepartmentAccessChecker는 포함하지 않았습니다.⛓️ 기존 기능에 미치는 영향
member도메인과infrastructure:db는 그대로 동작합니다.MemberJpaEntity생성 방식이 바뀝니다.new MemberJpaEntity(member)→MemberJpaEntity.from(member). 현재 호출부가 없어 깨지는 곳은 없습니다.bootstrap이JWT_SECRET_KEY,JWT_ISSUER환경변수를 요구합니다. 루트.env.example에 항목을 추가했고, 테스트는bootstrap/src/test/resources/application-gateway-auth.yml이gateway:auth의 동명 파일을 가려 더미 값으로 뜹니다.gateway:auth → core:common단방향만 추가돼architecture.md3절을 따릅니다.core:common은 여전히 순수 Java입니다.🔀 Edge Case & 실패 시나리오
Authorization헤더 없음AuthorizationFilter가 막고 EntryPoint가 401Bearer접두사 아님InvalidTokenException→ExceptionTranslationFilter→ EntryPoint → 401sub가 숫자가 아님 / 모르는 enum 이름IllegalArgumentException을InvalidTokenException으로 감싸 401iss불일치requireIssuer로 검증 실패 → 401AccessDeniedException→RestAccessDeniedHandler→ 403JWT_SECRET_KEY가 32바이트 미만Keys.hmacShaKeyFor가 기동 시점에 실패 (조용히 넘어가지 않음)📋 검토한 대안과 선택 이유
1. 인증 실패 응답 경로 —
HandlerExceptionResolver위임 vs EntryPoint 직접 작성필터에서 발생한 예외는
@RestControllerAdvice가 잡지 못합니다. EntryPoint에서 JSON을 직접 쓰면 당장은 되지만,ApiResponse가api:common-api에 있어gateway:auth가 응답 규격을 중복 정의하게 됩니다(의존 방향상 참조 불가).HandlerExceptionResolver에 위임해GlobalExceptionHandler가 처리하도록 했습니다 —architecture.md7절의gateway:auth → spring-webmvc(예외 위임)와 일치합니다.2. 필터 위치 —
UsernamePasswordAuthenticationFilter앞 vsAuthorizationFilter앞관례적인
UsernamePasswordAuthenticationFilter앞은ExceptionTranslationFilter보다 앞이라, 필터가 던진 예외를 Security가 잡을 수 없습니다. 그래서 처음엔 필터가HandlerExceptionResolver를 직접 들고 있었는데, 인증 실패 처리가 필터와SecurityConfig두 곳으로 갈렸습니다. 필터를AuthorizationFilter앞으로 옮겨InvalidTokenException(=AuthenticationException)이ExceptionTranslationFilter를 통해 EntryPoint로 흐르게 했습니다. 결과적으로 필터에서try/catch와HandlerExceptionResolver의존이 사라지고, 인증 실패 응답을 만드는 곳이 두 핸들러로 단일화됐습니다.3.
SecurityConfig위치 —gateway:authvsapi:common-apiconfig-and-auth.md4-5절은gateway:auth,architecture.md2-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은 아직 호출부가 없습니다. 로깅 필터에서 헬스체크 로그를 거르는 용도 등을 염두에 뒀습니다.