public ResponseEntity putPost(@PathVariable("board") Post.Board board,
@RequestHeader(value = "Authorization") String token,
@RequestBody PostDto.Post requestBody) {
Base64.Decoder decoder = Base64.getDecoder();
String[] splitJwt = token.split("\\.");
String payload = new String(decoder.decode(splitJwt[1]
.replace("-", "+")
.replace ("_", "/")));
String email = new String(payload.substring(payload.indexOf("email") + 8, payload.indexOf("com")+3));
Member member = memberService.findMemberByEmail(email);
Post post = postMapper.postPostToPost(requestBody);
post.setMember(member);
post.setBoard(board);
Post response = postService.createPost(post);
return new ResponseEntity<>(postMapper.postToPostResponse(response),
HttpStatus.CREATED);
}
클라이언트 요청이 들어올 때 헤더에 들어오는 JWT 토큰을 Decoder와 .split()
메서드로 뜯어서, 페이로드의 email
에 든 값을 확인하는 식으로 로그인한 유저에 대해 파악했다. 위 코드는 예시를 보여주기 위해 가져온 PostController(게시글 컨트롤러)의 게시글 등록 메서드다.
public void verifyWriterMember(String token, Long memberId) {
Base64.Decoder decoder = Base64.getDecoder();
String[] splitJwt = token.split("\\.");
String payload = new String(decoder.decode(splitJwt[1]
.replace("-", "+")
.replace ("_", "/")));
String email = new String(payload.substring(payload.indexOf("email") + 8, payload.indexOf("com")+3));
Long requestMemberId = findMemberByEmail(email).getMemberId();
if (requestMemberId != memberId) {
throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_ALLOWED);
}
회원 정보가 필요한 메서드는 등록 외에도 작성한 게시글, 질문, 댓글 등을 수정(patch) 및 삭제(delete) 할 때도 필요하다. 이 경우 여러 도메인에서 공통적으로 사용할 메서드가 필요하다고 판단하였고, 회원 정보를 확인하는 것이기 때문에 MemberServiceImpl
에 verifyWriterMember()
라는 메서드는 만들어뒀었다.
JWT 토큰을 해체하여 email
값을 얻는 것까지는 똑같고, 요청하는 회원이 작성자와 동일한지 비교하기 위해 memberId
값을 비교했다. 다를 경우 MEMBER_NOT_ALLOWED
라는 예외를 던지게끔 했다.
프로젝트 당시에는 일정대로 구현을 해내느라 짧은 스프링 시큐리티와 JWT 지식으로 어떻게든 동작이 실행되게끔 하느라 이러한 방식으로 했다. 포스트맨으로 다른 회원이 수정 및 삭제를 요청하는 경우, 만료한 토큰으로 요청하는 경우 등 다양하게 테스트 했을 때 모두 의도한대로 동작하는 것을 확인했다.
1차적으로 구현을 완료한 후 고민해보았을 때, 들어오는 요청마다 토큰을 뜯어서 확인하는 방식이 도무지 효율적이지 않다는 생각과 함께 이 방식대로 간다고 해도 이 서비스가 앞으로 확장한다고 할 경우 토큰을 뜯는 동작을 글로벌한 메서드로 하나 만들어둬야겠다는 생각 등이 들었다. 그리고 마지막 멘토링시 멘토님이 구현 방식을 보시곤 이렇게 둘 경우 여러 가지 문제가 생길 수 있다고 조언을 해주셨기 때문에 프로젝트 기간 종료 후 가장 먼저 고치기로 정했다.
기존 방식에 어떤 문제점이 있는지 살펴보았다.
Base64.Decoder decoder = Base64.getDecoder();
String[] splitJwt = token.split("\\.");
String payload = new String(decoder.decode(splitJwt[1]
.replace("-", "+")
.replace ("_", "/")));
String email = new String(payload.substring(payload.indexOf("email") + 8, payload.indexOf("com")+3));
/**
- 매번 Decoder를 불러온다.
- 문자열로 들어온 토큰을 문자열 배열로 만든다.
- 배열의 1번째 값을 찾아 전부 읽으며 replace()하여 새로운 문자열 payload를 생성한다.
- payload 문자열을 전부 읽어서 특정 인덱스 사이의 email이라는 새로운 문자열로 만든다.
**/
Member member = memberService.findMemberByEmail(email);
/**
- memberService의 findMemberByEmail() 메서드를 쓴다. = 회원 DB에 select문 사용
**/
Post post = postMapper.postPostToPost(requestBody);
post.setMember(member);
post.setBoard(board);
Post response = postService.createPost(post)
/**
클라이언트가 전달해온 requestBody를 매퍼로 Post 객체로 만든다.
클라이언트에게 받지 못한 작성자와 게시판 정보를 세터로 정해주고,
createPost() 메서드로 게시글 DB에 저장한다.
**/
putPost
메서드는 게시글을 등록하는 기능이다. 그러나 기존 방식은 메서드의 존재 이유에 비해 너무 많은 기능을 넣어놨다. 문제라고 느낀 점은 아래와 같다.
위의 세 가지 점들을 바람직하지 못하다고 느꼈는데, 가장 문제라고 느낀 건 토큰 부분이었기 때문에 이 글에서는 이 문제점을 해결하는 내용을 다룬다.
스프링 시큐리티의 인증(Authentication)에 대해 처음부터 복습하기 위해 다른 것보다도 공식문서를 먼저 살펴봤다. 스프링 시큐리티 인증의 중심에는 SecurityContextHolder
가 있다. 이곳에 인증된 유저의 모든 정보를 담고 있다. 어떤 유저가 인증되었다고 나타내기 가장 단순한 방법이 SecurityContextHolder
를 설정하는 것이다.
private void setAuthenticationToContext(Map<String, Object> claims) {
String username = (String) claims.get("email");
List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List) claims.get("roles"));
Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
SecurityContextHolder
설정은 프로젝트에서 auth/filter
패키지 아래 JwtVerificationFilter
라는 클래스에 메서드로 설정해두었다.
(JwtVerificationFilter
는 JwtAuthenticationFilter
후에 작동하도록 SecurityConfig
에 .apply(new CustomFilterConfigurer())
로 필터체인에 적용되어있다.)
setAuthenticationToContext()
이 메서드로 클레임에서 얻은 유저네임과 권한 정보로 인증 객체를 만든다. 생성한 인증 객체를 SecurityContextHolder에 담아 추후에 인증된 회원인지 확인할 수 있게 설정해둔다.
(UsernamePasswordAuthenticationToken
은 username과 password를 나타내는 Authentication 인터페이스의 단순한 구현체다.)
결론적으로 SecurityConfig가 이미 인증 정보 세팅하는 작업을 수행하게끔 구현을 해놓았기 때문에 SecurityContextHolder에서 인증 정보를 꺼내서 쓰기만 하면 된다.
인증 정보를 꺼내는 다양한 방법들
같은 Principal이어도 접근하는 방식은 다양하다. 여러 블로그를 참고하고 코드를 적용해보며, 크게 다섯 가지를 추려보았다.
@Controller 애너테이션으로 등록된 Bean의 메서드는 매개변수 인자로 Authentication 객체를 받을 수 있다. (컨테이너가 메서드에 주입해준다.)
// 매개변수로 받는 방식 1
@GetMapping("/test1")
public String getMemberInfo(Authentication authentication) {
String email = authentication.getPrincipal().toString();
return email;
}
간단하게 로그인한 유저의 이메일(username)을 얻는 GET 메서드를 임시로 만들어보았다. (위에서 언급했듯이 SecurityContextHolder
설정을 JwtVerificationFilter
에서 JWT에서 얻은 클레임으로 하기 때문에, 헤더에 액세스토큰을 넣어주어야 한다.)
// 매개변수로 받는 방식 2
@GetMapping("/test2")
public String getMemberInfo2(UsernamePasswordAuthenticationToken authentication) {
String email = authentication.getPrincipal().toString();
return email;
}
위의 코드에서 차이점은 매개변수 타입을 UsernamePasswordAuthenticationToken
으로 받는다는 것이다. 동일하게 이메일 정보만 반환하기 때문에 눈의 띄는 차이는 없지만, 사용하고자 하는 타입이 있다면 이 방식을 고려할 수 있겠다.
// 매개변수로 받는 방식 3
@GetMapping("/test3")
public String getMemberInfo3(Principal principal) {
return principal.toString();
}
Authentication 객체 뿐만 아니라 Principal 객체도 주입 받을 수 있다. 이 실험용 메서드의 경우 Principal 객체 자체를 문자열로 반환했다.
UsernamePasswordAuthenticationToken [Principal=amugae@gmail.com, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]
때문에 위와 같은 문자열이 반환되었다. UsernamePasswordAuthenticationToken
이 반환된 이유는 앞서 살펴보았듯 필터 단에서 SecurityContextHolder
설정을 할 때 UsernamePasswordAuthenticationToken
타입으로 인증 객체를 생성해서 설정해놓았기 때문이다.
@GetMapping("/test4")
public String getMemberInfo4(@AuthenticationPrincipal Object principal) {
return principal.toString();
}
@AuthenticationPrincipal 애너테이션 공식문서:
Annotation that binds a method parameter or method return value to the Authentication.getPrincipal(). This is necessary to signal that the argument should be resolved to the current user rather than a user that might be edited on a form.
공식문서에 따르면 @AuthenticationPrincipal
은 매개변수로 입력 받는 인자의 리턴 값을 Authentication.getPrincipal()로 만들어주는 애너테이션이다. 따라서 1번에서 살펴본 Authentication 객체에 접근해 .getPrincipal()
을 하는 것과 사실상 동일하고, 포스트맨 결과로도 로그인 한 유저의 이메일이 반환되는 것을 확인할 수 있었다.
@GetMapping("/test5")
public String getMemberInfo5() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String email = auth.getPrincipal().toString();
return email;
}
마지막으로 SecurityContextHolder에 직접 접근해 Authentication 객체를 얻고, 거기서 필요한 유저 이메일 정보를 가져오는 방식이다. 위의 방식들과 차이가 있다면 컨테이너로부터 메서드의 매개변수로 입력 받는 것이 없다는 것이다.
@PostMapping("/{board}/write")
public ResponseEntity putPost(@PathVariable("board") Post.Board board,
@RequestHeader(value = "Authorization") String token,
@RequestBody PostDto.Post requestBody) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Member member = memberService.findMemberByEmail(auth.getPrincipal().toString());
Post post = postMapper.postPostToPost(requestBody);
post.setMember(member);
post.setBoard(board);
Post response = postService.createPost(post);
return new ResponseEntity<>(postMapper.postToPostResponse(response), HttpStatus.CREATED);
}
위의 방식들을 테스트 해보고 여러 사람들의 코드도 살펴보니, 구현 방식이 너무 다양해서 우리 프로젝트의 경우 어떤 방식으로 선택해야 할지 고민이었다.
로그인한 유저의 정보를 얻는 것은 한 번 설정된 SecurityContextHolder를 다시 확인하는 작업이기 때문에 어떤 방식을 선택하더라도 성능상 차이를 볼 수는 없겠다고 결론 지었다. 때문에 가장 기본적인 방식인 SecurityContextHolder에서 인증 객체를 가져와 사용하는 방식을 선택했다.
참고자료
Servlet Authentication Architecture
Annotation Type AuthenticationPrincipal
[Spring Security] 인증된 사용자 정보 조회
10. SpringSecurity 인증 후 로그인 객체는 어떻게?
How to Access the Current Logged-In Username in Spring Security
Spring Security - Get Current Logged-In User Details