[Feat/#5] JWT 기반 인증 필터 구현 - #6
Conversation
|
[r] 실제 부서목록 8개와 일치합니다. |
추가로 SecurityConfig 위치 기준이 서로 다른 부분은 후속 작업에서 하나로 통일하는 것으로 확인했습니다! |
|
코드 부분 확인 완료했습니다! 또한 isPublic이 현재 호출부가 없다고 나와있는데, 이후 실제로 연결할 때 SecurityConfig와 isPublic이 판단하는 경로가 일치하는지 개발할 때 한번씩 확인하면 좋을 것 같습니다! |
| 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)); | ||
| } |
There was a problem hiding this comment.
PathPattern을 미리 파싱해 두신 부분 좋습니다!
현재 isPublic 호출 시 매번 flatMap으로 모든 주소 목록을 새로 합쳐서 검사하는데, 서버로 요청이 몰리면 매번 목록을 합치느라 성능에 부하가 좀 생길 수도 있을 것 같아 나중에 전체 주소 패턴을 미리 하나의 static 리스트로 다 합쳐두고 가져다 쓰는 방식으로 개선해보아도 좋을 것 같습니다!
There was a problem hiding this comment.
이 부분같은 경우 성능은 무시 가능한 수준입니다.
isPublic()에서 실제 비용은 PathPattern.matches() 4회고, flatMap이 얹는 건 values() 방어적 복사와 Stream 객체 할당 정도라 요청 1건 처리 시간에 비하면 무시 가능한 수준입니다.
대신 요청마다 매번 리스트를 합치는 게 코드 해석 의미상 자연스럽다고 느껴지진 않아서 말씀하신 것처럼 리스트 따로 분리해서 반영하도록 하겠습니다~
|
코드와 컨벤션 문서 모두 잘 확인했습니다! |
@sangrae2325 맞습니다. 로깅 등 필터에서 직접 공개 api path를 제외해줘야 하는 경우가 생겨서 그렇습니다! |
fda6317 to
b188cf3
Compare
[Feat/#7] 공통 API 응답 포맷 추가
#️⃣연관된 이슈
🎯 해결하려는 문제가 무엇인가요?
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은 아직 호출부가 없습니다. 로깅 필터에서 헬스체크 로그를 거르는 용도 등을 염두에 뒀습니다.