Spring Security - SecurityContextHolder에서 로그인 사용자 정보 가져오기
kindof
·2022. 8. 3. 01:29
0. 문제
Spring Security, JWT을 이용해 로그인이나 권한 처리 등의 작업을 하다 보면 "로그인을 한 사용자의 정보를 어떻게 가져와야 할까"에 대한 고민을 하게 됩니다.
토큰을 어디에 저장하고 어떤 방식으로 사용하느냐에 따라 조금씩 다른 문제겠지만 근본적으로는 발급된 토큰을 가지고 현재 권한을 가진 사용자가 자유롭게 서비스를 이용할 수 있어야 하고, 해당 사용자가 누구인지 서버에서도 바로 알 수 있어야 하는 게 당연하기도 합니다.
그럼에도 불구하고 이 내용에 대해 글을 쓰기 전에는 토큰이 아니라 아래처럼 사용자 자체가 가지고 있는 필드 정보를 가지고 DB에서 사용자를 다시 찾아서 사용했었습니다.
- 사용자의 Email, Username, Nickname, PK 등의 고유값을 입력(전달)받는다.
- 해당 값을 바탕으로 쿼리를 날려 사용자를 DB에서 가져온다.
완전히 불필요한 과정이죠.
그래서 이번 포스팅에서는 JWT를 통한 사용자 인증 과정에 대해 조금 더 깊게 살펴보면서, SecurityContextHolder 클래스를 통해 서버에서 로그인한 사용자 정보를 읽어오는 방법에 대해 소개하려고 합니다.
이전에 JWT를 이용한 로그인 처리에 대한 개념을 정리한 글을 정리해둔 것이 있는데, 기본적인 내용이 궁금하신 분은 아래 링크를 참고해주시면 감사하겠습니다🍀
1. JWT를 이용한 로그인 과정
JWT를 이용해서 사용자 인증(로그인)을 하는 과정에 대해 이해하기 위해 아래 Document를 천천히 읽어봤습니다.
Doc에서는 가장 먼저 DelegatingFilterProxy 라는 인터페이스에 대해 소개하고 있는데요.
클라이언트가 로그인을 시도할 때, 가장 처음에 서블릿의 형태로 위 그림에서 Filter_0(ApplicationContext)에 도달하게 됩니다.
그리고 ApplicationContext를 지난 요청이 DelegatingFilterProxy에 도달하는데, 이 녀석은 Delegating이라는 단어가 의미하듯 클라이언트의 요청을 다른 필터에 '위임'하는 역할을 합니다.
Springboot에서는 build.gradle에 아래와 같이 설정한 내용이 위와 같은 Spring Security 아키텍쳐를 자동으로 설정해줍니다.
implementation 'org.springframework.boot:spring-boot-starter-security'
지금까지 설명한 것처럼, DelegatingFilterProxy에 도달한 요청은 FilterChainProxy로 위임됩니다.
FilterChainProxy는 위 그림처럼 LogoutFilter, UsernamePasswordAuthenticationFilter 등으로 이루어져 있는데요.
일반적으로 우리는 Security 설정을 위한 클래스 파일을 생성하여 WebSecurityConfigureAdapter를 Extends하는 코드를 작성하게 되는데, 이 때 어떤 커스텀 필터를 사용할 지에 대해 본격적으로 결정하게 됩니다.
예를 들어, 제가 작성한 SecurityConfig 클래스는 아래와 같습니다.
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final CorsFilter corsFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// WebSecurityConfigureAdapter의 configure 메서드 오버라이드
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable()
.addFilter(corsFilter)
.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/**").permitAll()
.antMatchers("/users/**").permitAll()
.anyRequest().authenticated();
}
}
httpSecurity ~ 뒤로 이어지는 부분을 보면 제가 커스텀화 한 corsFilter를 적용하고 UsernamePasswordAuthenticationFilter를 적용하기 전에 커스텀화하여 작성한 JwtFilter를 적용하라는 명령이 있습니다.
FilterChanProxy가 가지는 필터들 사이에 사용자가 커스텀화 한 필터들을 추가하여 클라이언트의 요청을 검증하게 되고, 최종적으로 모든 필터를 통과할 때 그 다음으로 넘어갈 수 있도록 하는 것이죠.
아래 그림은 로그인 요청을 보냈을 때 디버거를 통해 JwtFilter가 동작하고 있음을 보여줍니다. BreakPoint를 위쪽에 설정하고 진행을 해보면 현재는 발급받은 토큰이 없기 때문에 filterChain.doFilter() 메서드로 넘어가게 됩니다.
그리고 실제로 디버거를 Step Over해서 살펴보면 HeaderWriterFilter부터 시작해서 여러 필터들을 차례대로 거치게 됩니다.
이 과정이 모두 끝나면 이제서야 Controller에 매핑된 Login 로직이 실행되게 됩니다.
[MemberService.java]
public ResponseEntity<MemberDTO.TokenDTO> login(@Valid @RequestBody MemberDTO.LoginDTO memberLoginDto) {
CustomUserDetailsImpl userDetails = (CustomUserDetailsImpl) customUserDetailsService.loadUserByUsername(memberLoginDto.getUsername());
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, memberLoginDto.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = tokenProvider.createToken(authentication);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
return new ResponseEntity<>(new MemberDTO.TokenDTO(jwt), httpHeaders, HttpStatus.OK);
}
위 코드는 실제 login 요청을 처리하는 @Service 로직입니다. 위 코드를 잘 보면 CustomUserDetailsImpl 이라는 클래스가 존재합니다.
[CustomUserDetailsImpl.java]
@Getter
public class CustomUserDetailsImpl extends User {
private final Member member;
public CustomUserDetailsImpl(Member member, Collection<? extends GrantedAuthority> authorities) {
super(member.getUsername(), member.getPassword(), authorities);
this.member = member;
}
}
CustomUserDetailsImpl은 User를 상속받아 뒤에서 살펴볼 CustomUserDetailsService의 loadUserByUsername() 메서드를 통해 클라이언트의 정보를 담고있는 객체 구조입니다.
이 객체를 로그인 시 SecurityContextHolder의 Principal 필드에 저장함으로써 나중에 다시 SecurityContextHolder에서 꺼내올 때 DB에서 조회하지 않아도 되는 편의성을 확보할 수 있는 것입니다.
[CustomUserDetailsService.java]
@Service
@RequiredArgsConstructor
@Transactional
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(final String username) {
Member member = memberRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
List<GrantedAuthority> grantedAuthorities = member.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
.collect(Collectors.toList());
return new CustomUserDetailsImpl(member, grantedAuthorities);
}
}
CustomUserDetailsService에서는 loadUserByUsername() 메서드를 오버라이딩합니다. 해당 메서드는 UserDetailsService 인터페이스에 정의되어 있는데요.
해당 메서드는 authenticate() 메서드를 호출 시 사용되기 때문에 인증 수행 시 이 메서드를 통해 CustomUserDetailsImpl 타입의 객체를 생성하여 Principal에 등록해버리는 트릭이라고 볼 수 있습니다.
2. SecurityContextHolder를 이용해서 사용자 정보 읽어오기
[1] ClientMemberLoader 객체 활용하기
위에서 설명한 내용을 이해했다면 SecurityContextHolder에서 사용자 정보를 읽어오는 것은 간단합니다. 이미 로그인을 하는 순간 SecurityContextHolder에 Member 객체를 Principal로 저장해두었기 때문인데요.
그래서 아래와 같이 ClientMemberLoader 라는 클래스를 @Component를 통해 빈으로 생성해두고 다른 곳에서 주입받아서 편리하게 사용할 수 있습니다.
[ClientMemberLoader.java]
@Component
public class ClientMemberLoader {
public Member getClientMember() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.getPrincipal() instanceof CustomUserDetailsImpl) {
return ((CustomUserDetailsImpl) authentication.getPrincipal()).getMember();
}
return null;
}
}
코드에서 SecurityContextHolder.getContext().getAuthentication()을 호출하면 Authentication 객체가 리턴됩니다.
해당 객체의 principal은 우리가 이전에 loadUserByUsername() 메서드를 통해 CustomUserDetailsImpl 객체로 저장해두었기 때문에 Member 도메인을 그대로 사용할 수 있게 됩니다.
[2] @AuthenticationPrincipal 어노테이션 사용하기
위 내용을 좀 더 편리하게 어노테이션으로 정의해서 사용할 수도 있습니다.
[CurrentUser.java]
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")
public @interface CurrentUser {
}
아래 링크에서 @CurrentUser를 활용하는 방법에 대해 좀 더 자세하게 설명하고 있는데요.
https://docs.spring.io/spring-security/site/docs/5.2.1.RELEASE/reference/htmlsingle/
이 방식은 @AuthenticationPrincipal 애노테이션을 사용했을 때는 UserDetailsService에서 Return한 객체를 파라미터로 직접 받아 사용할 수 있다는 점을 활용한 것입니다.
그러면 같은 맥락에서 우리는 loadUserByUsername을 오버라이딩해서 CustomUserDetailsImpl 객체를 리턴했고, 이를 Principal로 저장했기 때문에 [1]에서 설명한 방식과 동일한 효과를 얻을 수 있는 것이죠.
3. 마무리
이번 포스팅에서는 SecurityContextHolder를 통해 로그인한 사용자 정보를 쉽게 읽어오는 방법에 대해 살펴봤습니다.
로그인 과정에 대한 설명이 약간 투박한 느낌이 있고, 커스텀화 한 필터의 로직이 부족하다(불필요하거나 잘못됐다)는 느낌이 듭니다.
이후에 Spring Security의 동작이나 여러가지 필터와 핸들러들에 대해 하나씩 조금 더 깊게 공부를 해서 정리해보겠습니다.
감사합니다.
4. Reference
https://velog.io/@arotein/Spring3-2
https://okky.kr/article/1107984?note=2597774
https://docs.spring.io/spring-security/site/docs/5.2.1.RELEASE/reference/htmlsingle/
'Spring & Springboot' 카테고리의 다른 글
Spring AOP 스터디 - (2) @Aspect 기반 AOP 적용 (0) | 2022.10.21 |
---|---|
Spring AOP 스터디 - (1) AOP의 필요성과 기본적인 동작 원리 (0) | 2022.10.18 |
Entity 필드가 가지는 Enum값의 목록은 어떻게 가져와야 할까? (0) | 2022.05.20 |
리액트 / 스프링 데이터 JPA 환경에서 커서를 통한 페이지네이션(Pagination) 구현하기 (0) | 2022.05.18 |
@ControllerAdvice로 Validation 예외 처리하기 (1) | 2022.03.27 |