toy/AgileHub

인증/인가 구현 및 리뷰과정에서 발생한 트러블 슈팅

EVO. 2024. 4. 29. 17:47
팀원이 먼저 인증 API를 구현하고난뒤 함께 리뷰하면서 생겼던 트러블 슈팅을 적어보려 한다.

- Spring Security 6.1
- SpringBoot 3.2.3
- Redis 5.0.7
- java-jwt 4.4.0
- oauth2-client

 

1. base 64 디코딩 에러 발생

GitHub CLI gh로 PR을 로컬에 받고 테스트 도중 이상한 것을 발견했다. postman을 통해 api를 요청할 때 

Authorization 헤더에 bearer: { accessToken } 을 넣고 API를 요청을 했다. e.g) /api/projects

응답 결과는 다음과 같이 Token 유효성을 검사 도중 유효하지 않은 JSON format이라는 에러 메세지를 받았다.

JwtUtil.verifyToken error: The string '�G�&�r#�$�3S""�'G�#�$�uB'' doesn't have a valid JSON format.

 

원인을 제대로 알 수 없기 때문에 JwtUtil 근처 부터 의심가는 곳에 도트를 찍으며 흐름을 다시 따라가봤다.

포스트맨에서 보낸 accessToken은 잘 분리되어서 반환된다.
verify에서 해당 토큰을 검사시작

 

 

파란줄로 밑줄 친 headerJson을 눈여겨보자. accessToken을 점(.)을 기준으로 앞 뒤로 분리되어 앞에는 headerJson 뒤에는 점(.)앞에게 들어가 base64에 의해 파싱되고 

 

뒤에 payload는 점(.) 뒤에게 들어가 base64에 의해 파싱된다.

 

위 사진에 보다 싶이 문제가 없다고 생각할 수 있었지만 headerJson이 파싱이 된 순간 부터 갑자기 깨졌다. 그런데 뒤 payloadJson은 잘 파싱되었다.

 

문제 원인 및 해결

로직 중 JWT 토큰 생성 과정에서 문제가 있었다.

 

기존 코드에서 PREFIX = "Bearer "를 JWT access Token 및 Refresh Token 을 만드는 과정에서 String 타입 그대로 연결한 반면 뒤에 OAuth 서버에서 받은 내용들은 Base64로 잘 인코딩 했기 때문에 headJson만 Base64로 디코딩하는 과정에서 문제가 생겼던 것이다.

 

또한 accessToken에 "Bearer "이라는 내용이 들어갈 이유가 없다고 판단해서 제거해버린 뒤 이 부분은 해결했다.

 

위에서 보다싶이 withHeader을 따로 작성하지 않았는데 해결한 후에도 accessToken에는 점을 구분자로 헤더가 있었는데 그 이유는 JWT create과정에서 기본적으로 JWTCreater 클래스 자신을 반환한다.

 

따라서 해결되기전에는 (인코딩안된 String의 "Bearer ")+Base64로 인코딩된 디폴트 헤더 + (.) + Base64로 인코딩된 Payload 로 이루어져 문제가 생겼던 것이다.

 

2. NullPointerException 발생

다시 같은 방법으로 포스트맨에 'Bearer {accesstoken}'을 넣고 GET /api/projects API를 요청했다. 이번엔 OAuth2UserInfo 클래스에서 getNickname() 메서드를 호출하는 과정에서 NullPointerException 예외가 발생했다.

 

이미 한번 쭉 리뷰를 했지만 역시나 내가 직접 구현한게 아니라서 확신이 없었기 때문에 의심가는 곳에 레드다트를 찍어가며 디버깅과 로그를 찍어보며 확인했다.

 

문제 원인

JWTAuthFilter 클래스에서 로직을 살펴보면 AccessToken이 정상적인 토큰인 것을 확인하면 saveAuthentication(String acessToken) 메서드에서 해당 accessToken을 받고 Provider 부분(KAKAO)과 distinctId을 추출한다. 그리고나서 SecurityContextHoldersetAuthentication() 메서드를 통해 SecurityContext에 인증정보를 저장하는 로직이 있다. 

 

 

그런데 그 로직중에서 provider와 distinctId로 멤버테이블에서 Member를 찾고 SecurityMember에 생성자 주입을 한다. 

현재 SecurityMember을 보면 UserInfoMember 변수를 받을 수 있는데 UserInfo는 null인 상태이다.

 

 

이때 UsernamePasswordAuthenticationToken에 전달하고 시큐리티 필터 내부에 UserDetails을 오버라이드 받은 SecurityMember의 getUsername() 메서드를 실행한다.

 

그런데 SecurityMembergetUsername()에는 userInfo.getNicname()으로 로직이 실행되고 있어서 NullPointerException이 발생한 것이었다.

 

문제 해결

getUsername()에서 반환을 member.getName()을 하던지, Oauth2UserInfo를 채워주는 로직을 넣어야 했다. 내부로직상 Member에서 정보를 가져오는 것이 맡기 때문에 OSIV를 고려해 트랜잭션 내에서 Member의 메서드를 강제 호출하는 방식으로 해결하여 LazyInitializationException 없이 가져올 수 있게 해결을 했다.

 

이제 정상적으로 포스트맨에서 GET /getProjects를 호출하면 잘 응답이 반환되었다.

 

해당 과정은 다음 PR에서 자세히 볼 수 있다. https://github.com/AgileHub-DQ/Backend/pull/62

 

[#29] 인증 및 인가로직 구현 by Enble · Pull Request #62 · AgileHub-DQ/Backend

📄 Summary Spring Security + OAuth2 + JWT 이용한 인증/인가로직의 구현입니다. 아직 테스트에서 모든 Controller layer가 통과되지 않아 수정이 필요합니다. 🕰️ Actual Time of Completion 1달 🙋🏻 More {host}/oauth

github.com

 

 

 

3. JWT 필터로 인한 컨트롤러 테스트 미통과 문제

컨트롤러 테스트를 할 때 Mock객체를 사용하기 위해 WebMvcTestMockBean으로 http만 확인하는 테스트 코드가 통과를 못하고 있다.

 

원인은 WebMvcTest는 Filter는 무조건 거치게 되는데 JWTAuthFilter에서 모킹처리를 하게되면 컨트롤러에서 201이 아닌 403이 뜨게 되고 모킹처리를 안하게 되면 JWTAuthFilter 클래스 내에서 의존성있는 객체도 전부다 받아와할텐데 거기서 어떻게 해야할지 복잡해져서 주석처리하고 시간관계상 미뤘다. 

 

문제 해결

드디어 문제를 해결했다. 해결방법은 다음과 같다.

 

해당 API의 컨트롤러 테스트를 통과하기 위해서 여러 모킹이 필요하다. 대충봐도 @Auth 관련해서 조치가 필요하고, projectService에서도 모킹이 필요해보인다.

 

 

위 테스트코드와 WebMvcTest 어노테이션을 사용했다. @WebMvcTest는 Web Layer에 속하는 Security, Filter, Interceptor, request/response Handling, Controller은 빈으로 등록되지만 나머지 Service나 Repository와 같이 Component로 등록된 것들은 주입이 되지 않는다. 따라서 Mock 객체로 등록해야한다.

 

Security 관련 필터는 자동 등록되지만, 우리가 설정한 SpringSecurityConfig 설정파일은 등록이 되지않는다. 그래서 이를 Mock이 아닌 빈으로 등록하기 위해서 @Import 를 사용해야 한다. 또한 @Auth는 MemberArgumentResolver에 의해 관리된다. 이 역시 Mock으로 등록하기에는 애매하기 때문에 이 역시  @Import를 사용하여 빈으로 등록하자.

 

 

이대로 돌리면 JwtAuthFilter는 빈으로 등록될텐데 JwtAuthFilter가 자동주입으로 @Component를 붙인 JwtUtil, MemberQueryService, RefreshTokenRedisService 때문에 JwtAuthFilter 빈을 생성할 수 없게 되었다. 때문에 이들을 MockBean으로 등록해야 한다. 

 

그뿐아니라 CustomOauth2UserService와 Oauth2SucessHandler는 SpringSecurityConfig에서 사용되는 @Componet 클래스인데 이 역시 Mock으로 등록해야한다. 최종적으로 다음과 같다.

 

 

빈으로 등록할게 정말 많아졌는데 다음에는 필터를 구성할때 서비스 클래스는 적당히 만드는게 좋을 것 같긴하다.

 

여기서 끝이 아니다. 이제 JwtAuthFilter 로직을 보면 jwtUtil을 가지고 사용하는 로직이 있는데 이역시 다 반환 값을 정해줘야 한다.

 

 

이렇게 하면 컨트롤러 테스트가 통과할 수 있다. 

4. Github actions CI 미통과 문제 

로컬에서는 테스트코드가 통과했지만 PR에서 진행되는 CI 테스트는 미통과된다. 그 이유는 환경변수를 주입받지 못했기 때문인데, CI 워크플로우에 Redis HOST와 jwt secretkey만 주입해주면 통과된다.

5. CD 스크립트 미통과 문제 

이번엔 CD 배포과정에서 문제가 생겼다. CD 실패로그를 살펴보니 DockerFile에서 문제가 생긴 듯 보인다.

 

문제 원인

도커파일에서 Gradle 빌드를 실행해서 JAR파일을 생성하는 로직이 있다. 그런데 Gradle 테스트할때도 환경변수를 주입해야하는데 내가 역시나 환경변수를 주입하지 않아서 발생한 문제였다.

 

문제 해결

이 문제는 조금 다른게 깃헙 시크릿 토큰을 주입하는 방식도 아니고 Docker File에 변수를 직접 넣어야하는 방식이기 때문에 어떻게 해야 안전하게 보낼까 고민을 해야했다.

 

테스트할 때는 레디스와 JWT 토큰이 필요가 없다. 그렇지만 SpringBootTest를 할때 빈을 어쨋거나 생성을 해야하기 때문에 환경변수 주입이 필요했다. 하지만 해당 토큰을 쓴다던지 레디스를 쓰는 테스트를 하지는 안하기 때문에 임의의 무작위 토큰을 넣고 테스트를 돌려봤다. 다행히 잘 빌드되고 배포가 되었다. 

DockerFile

 

6. 배포 후 api 경로 문제로 인증 실패

문제 원인

기존에 api를 만들때 웹서버 도메인과 api 도메인 모두 www로 시작하는 도메인으로 하고 뒤에 /api를 붙임으로써 Nginx가 프록시를 통해 구별해서 전달한다.

 

이때 /api을 무조건 붙여야한다는 단점과 실수로 빠뜨리는 문제도 있어서 아예 /api를 제거해버리자는 결론이 나왔다. 따라서 하위도메인 api.으로 시작하는 도메인을 만들었다.

 

문제 해결

1. 가비아에서 api. 하위 도메인 생성

2. nginx conf 리버스 프록시 쪽 설정 수정 

이때 기존에 certbot으로 SSL 인증서를 발급받았던 적이 있었는데 여기에 추가적으로 api 하위도메인도 추가해야했다. 해당 부분은 아래 링크를 통해 해결했다.

https://blog.orbithv.dev/certbot-domain-edit

 

3. 코드 내에 /api -> /로 전체 수정

 

7. cors 오류

문제 원인

스프링 부트에서 CORS 설정 시, .allowCredentials(true)와 .allowedOrigins("*")를 동시에 사용할 수 없도록 업데이트 되었다. 따라서 POST 요청시 CORS 오류가 생겼다

 

문제 해결

.allowedOrigins("*") → .allowedOriginPatterns("*")

 

 

이제 정상적으로 배포된 서버에서도 API 요청이 이루어짐을 확인할 수가 있다.