지난 미션 리뷰
JWT 필터 (JwtAuthenticationFilter) 만들어보기
- HTTP 요청 헤더에서 JWT 토큰이 있는지 확인
- JWT 토큰에서 loginId, roles을 추출하여 UsernamePasswordAuthenticationToken을 생성
- 앞서 만든 UsernamePasswordAuthenticationToken를 SecurityContext에 넣어줌
- JWT 필터는 Spring Security 필터 체인에 추가 (어디에 추가하면 좋을지 고민)
- 필터를 추가한 후 HTTP 요청에 JWT 토큰을 추가하면, GET /api/user/me API 호출이 성공해야 함
- UserRestControllerTest 테스트를 통과해야 함
솔루션
- 필터 위치 — SecurityContextPersistenceFilter 필터 바로 뒤
- SecurityContextPersistenceFilter 필터 앞에 위치하게 되면 SecurityContextPersistenceFilter 필터가 SecurityContext를 덮어 써버림
@Bean public Jwt jwt() { return new Jwt( jwtConfigure.getIssuer(), jwtConfigure.getClientSecret(), jwtConfigure.getExpirySeconds() ); } public JwtAuthenticationFilter jwtAuthenticationFilter() { Jwt jwt = getApplicationContext().getBean(Jwt.class); return new JwtAuthenticationFilter(jwtConfigure.getHeader(), jwt); } @Override protected void configure(HttpSecurity http) throws Exception { http /** * Jwt 필터 추가 */ .addFilterAfter(jwtAuthenticationFilter(), SecurityContextPersistenceFilter.class) ; }
- 필터 구현
- 이미 인증된 사용자 인지 확인해야 함 — 이미 인증된 사용자라면 아무처리 하지 않음
- getToken 메소드 — JWT 토큰을 HTTP 헤더에서 가져옴
- verify 메소드 — JWT 토큰을 검증하고 디코딩함
- UsernamePasswordAuthenticationToken의 principal 필드에는 loginId를 입력
- 마지막에 SecurityContextHolder.getContext().setAuthentication 메소드를 호출해 UsernamePasswordAuthenticationToken 객체 참조를 전달함
@Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; if (SecurityContextHolder.getContext().getAuthentication() == null) { String token = getToken(request); if (token != null) { try { Jwt.Claims claims = verify(token); log.debug("Jwt parse result: {}", claims); String username = claims.username; List<GrantedAuthority> authorities = getAuthorities(claims); if (isNotEmpty(username) && authorities.size() > 0) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, authorities); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { log.warn("Jwt processing failed: {}", e.getMessage()); } } } else { log.debug("SecurityContextHolder not populated with security token, as it already contained: '{}'", SecurityContextHolder.getContext().getAuthentication()); } chain.doFilter(request, response); }
REST API with JWT 제대로 해보기
앞서 8장에서 REST API 프로젝트를 구성하고, JWT를 적용해보았다.하지만 해당 구현은 아직 완전하지가 않다. 인증 API를 추가하고, 코드를 리팩토링해보자.
Jwt 전용 Authentication 인터페이스 구현체 추가
- 지금까지 Authentication 인터페이스 구현체로 Spring Security에서 기본 제공하는 UsernamePasswordAuthenticationToken 을 사용함
- JWT 인증 처리라는 것을 명확히 하기 위해 Authentication 인터페이스 구현체로 JwtAuthenticationToken 클래스를 추가함 (더이상 UsernamePasswordAuthenticationToken 사용 안함)
Remember-me 기능의 경우에도 Remeber-me 인증 처리라는 것을 명확히 하기 위해 Authentication 인터페이스 구현체로 RememberMeAuthenticationToken 클래스가 정의가 되어 있다.
- 또한, UsernamePasswordAuthenticationToken 클래스에서 인증된 사용자의 principal 타입으로 org.springframework.security.core.userdetails.User (UserDetails 인터페이스 구현체) 타입이 사용되었는데, 이를 교체하기 위해 JwtAuthentication 클래스를 추가함
- JwtAuthenticationToken, JwtAuthentication 2개의 클래스 추가 자체는 간단하지만, 이로 인하여 Spring Security의 인증 처리 기능 일부를 커스터마이징 해야함
public class JwtAuthentication { public final String token; public final String username; JwtAuthentication(String token, String username) { checkArgument(isNotEmpty(token), "token must be provided."); checkArgument(isNotEmpty(username), "username must be provided."); this.token = token; this.username = username; } @Override public String toString() { return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) .append("token", token) .append("username", username) .toString(); } } public class JwtAuthenticationToken extends AbstractAuthenticationToken { private final Object principal; private String credentials; public JwtAuthenticationToken(String principal, String credentials) { super(null); super.setAuthenticated(false); this.principal = principal; this.credentials = credentials; } JwtAuthenticationToken(Object principal, String credentials, Collection<? extends GrantedAuthority> authorities) { super(authorities); super.setAuthenticated(true); this.principal = principal; this.credentials = credentials; } // ... 생략 ... }
JwtAuthenticationProvider 만들기, 그리고 더이상 UserDetailsService 인터페이스에 의존하지 않기
- 앞서 추가한 JwtAuthenticationToken 타입을 처리할 수 있는 AuthenticationProvider 인터페이스 구현체가 없음
UsernamePasswordAuthenticationToken 타입을 처리할 수 있는 AuthenticationProvider 인터페이스 구현체로 DaoAuthenticationProvider 클래스가 있다. 또한 DaoAuthenticationProvider 구현체는 UserDetailsService 인터페이스에 의존한다. 8장까지는 Spring Security가 제공하는 기본 인프라스트럭쳐를 최대한 이용하려 했고, 이런 이유로 UserService 클래스가 UserDetailsService 인터페이스 구현체 역할을 했다.
- 따라서, JwtAuthenticationToken 타입을 처리할 수 있는 AuthenticationProvider 인터페이스 구현체를 추가 — JwtAuthenticationProvider
- JwtAuthenticationToken 타입을 처리할 수 있음
- UserService 클래스를 이용해 로그인을 처리하고, JWT 토큰을 생성함
- UserService 클래스는 더이상 UserDetailsService 인터페이스를 구현하지 않음
- 인증이 완료된 사용자의 JwtAuthenticationToken 을 반환함
- principal 필드 — JwtAuthentication 객체
- details 필드 — com.prgrms.devcourse.user.User 객체 (org.springframework.security.core.userdetails.User와 명백히 다름에 주목)
@Override public boolean supports(Class<?> authentication) { return JwtAuthenticationToken.class.isAssignableFrom(authentication); } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthentication = (JwtAuthenticationToken) authentication; return processUserAuthentication( String.valueOf(jwtAuthentication.getPrincipal()), jwtAuthentication.getCredentials() ); } private Authentication processUserAuthentication(String principal, String credentials) { try { User user = userService.login(principal, credentials); List<GrantedAuthority> authorities = user.getGroup().getAuthorities(); String token = getToken(user.getLoginId(), authorities); JwtAuthenticationToken authenticated = new JwtAuthenticationToken(new JwtAuthentication(token, user.getLoginId()), null, authorities); authenticated.setDetails(user); return authenticated; } catch (IllegalArgumentException e) { throw new BadCredentialsException(e.getMessage()); } catch (DataAccessException e) { throw new AuthenticationServiceException(e.getMessage(), e); } }
API 추가 — 인증 (로그인) API, 내정보 조회 API
- 인증 처리는 AuthenticationManager를 그대로 이용할 수 있음
- 인증 요청은 JwtAuthenticationToken 객체를 만들어 AuthenticationManager를 통해 처리할 수 있음
- 단, 앞서 만들어본 JwtAuthenticationProvider를 AuthenticationManager에 추가해야 함
- JwtAuthenticationProvider 객체를 Bean으로 등록
- AuthenticationManagerBuilder를 통해 AuthenticationManager에 JwtAuthenticationProvider 객체 참조를 추가함
- authenticationManagerBean 메소드를 호출하여 AuthenticationManager 객체를 Bean으로 등록
@Bean public JwtAuthenticationProvider jwtAuthenticationProvider(Jwt jwt, UserService userService) { return new JwtAuthenticationProvider(jwt, userService); } @Autowired public void configureAuthentication(AuthenticationManagerBuilder builder, JwtAuthenticationProvider authenticationProvider) { builder.authenticationProvider(authenticationProvider); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); }
- JwtAuthenticationFilter 수정
- JWT 토큰을 검증하고, 디코딩한 다음 JwtAuthenticationToken 객체를 생성 — UsernamePasswordAuthenticationToken 대체
- principal 필드 — JwtAuthentication 객체
- details 필드 — WebAuthenticationDetails 객체 (클라이언트 IP 정보를 지니고 있음)
- SecurityContextHolder.getContext().setAuthentication 메소드를 호출해 JwtAuthenticationToken 객체 참조를 전달함
- 내정보 조회 API 구현 — @AuthenticationPrincipal
- Authentication 인터페이스 구현체에서 principal 필드를 추출하는 어노테이션
- JwtAuthenticationToken 타입이 사용되었다면 JwtAuthentication 객체를 의미함
- 적절한 권한 (USER 또는 ADMIN)이 없다면 스프링 시큐리티 인가 처리 과정에서 예외가 발생하고, 내정보 조회 API 자체가 호출되지 않음
- 따라서, 내정보 조회 API 가 정상 호출되는 상황에서 JwtAuthentication 객체가
null
상태인 경우는 없음
JwtAuthentication authentication = (JwtAuthentication) SecurityContextHolder.getContext().getAuthentication().getPrincipal()
HandlerMethodArgumentResolver
Controller 메소드 호출에 필요한 파리미터를 바인딩 시키기 위한 인터페이스이다. 좀 더 세부적으로는 supportsParameter 메소드가 true를 반환하는 경우 resolveArgument 메소드가 실행되어 Controller 메소드 호출에 필요한 파라미터를 만들게 된다.
@RestController @RequestMapping("/api") public class UserRestController { private final UserService userService; private final AuthenticationManager authenticationManager; public UserRestController(UserService userService, AuthenticationManager authenticationManager) { this.userService = userService; this.authenticationManager = authenticationManager; } @GetMapping(path = "/user/me") public UserDto me(@AuthenticationPrincipal JwtAuthentication authentication) { return userService.findByLoginId(authentication.username) .map(user -> new UserDto(authentication.token, authentication.username, user.getGroup().getName()) ) .orElseThrow(() -> new IllegalArgumentException("Could not found user for " + authentication.username)); } @PostMapping(path = "/user/login") public UserDto login(@RequestBody LoginRequest request) { JwtAuthenticationToken authToken = new JwtAuthenticationToken(request.getPrincipal(), request.getCredentials()); Authentication resultToken = authenticationManager.authenticate(authToken); JwtAuthentication authentication = (JwtAuthentication) resultToken.getPrincipal(); User user = (User) resultToken.getDetails(); return new UserDto(authentication.token, authentication.username, user.getGroup().getName()); } }
JwtAuthenticationFilter를 다른 구현으로 대체할 수 있을까?
- JwtAuthenticationFilter의 핵심 역할은 HTTP 요청헤더에서 JWT 토큰을 확인하고, 검증하여 SecurityContext 를 생성하는 것
// JWT 토큰 가져옴 String token = getToken(request); // JWT 디코딩 Jwt.Claims claims = verify(token); // JwtAuthenticationToken 객체를 생성하고 SecurityContext에 참조를 넘겨줌 JwtAuthenticationToken authentication = new JwtAuthenticationToken(new JwtAuthentication(token, username), null, authorities); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication);
- 사실 이와 비슷한 역할을 수행하는 Spring Security 기본 필터가 이미 있음 — SecurityContextPersistenceFilter
SecurityContextPersistenceFilter
Populates the SecurityContextHolder with information obtained from the configured SecurityContextRepository prior to the request and stores it back in the repository once the request has completed and clearing the context holder. By default it uses an HttpSessionSecurityContextRepository
- SecurityContextPersistenceFilter 구현을 살펴보면, SecurityContextRepository에서 SecurityContext을 읽어옴
- SecurityContextRepository는 HTTP 요청에서 필요한 데이터를 얻고, 이 데이터를 이용함
- 따라서, HTTP 요청에서 필요한 데이터를 얻을 때, JWT 토큰을 이용하면 되지 않을까? — JwtSecurityContextRepository
- 사실상 JwtAuthenticationFilter 구현의 대부분을 JwtSecurityContextRepository 구현으로 가져올 수 있음
- 단, SecurityContextRepository 인터페이스에서 요구하는 메소드 구현을 모두 완료할 수 있어여함
- saveContext 메소드 — SecurityContext를 어딘가에 저장하여, 필요할때다시 읽어올 수 있도록 함
- containsContext 메소드 — 해당 HTTP 요청이 SecurityContext를 포함하고 있는지 여부를 확인할 수 있음
- 즉, SecurityContextPersistenceFilter에서 JwtSecurityContextRepository를 사용하도록 설정하면, JwtAuthenticationFilter의 역할을 SecurityContextPersistenceFilter에서 수행하게 됨
- 주의할 점은 SecurityContextRepository 인터페이스 구현체를 SessionManagementFilter에서도 사용한다는 것 — containsContext 메소드 호출 부분
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { if (request.getAttribute(FILTER_APPLIED) != null) { chain.doFilter(request, response); return; } request.setAttribute(FILTER_APPLIED, Boolean.TRUE); if (!this.securityContextRepository.containsContext(request)) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && !this.trustResolver.isAnonymous(authentication)) { // The user has been authenticated during the current request, so call the // session strategy try { this.sessionAuthenticationStrategy.onAuthentication(authentication, request, response); } catch (SessionAuthenticationException ex) { // The session strategy can reject the authentication this.logger.debug("SessionAuthenticationStrategy rejected the authentication object", ex); SecurityContextHolder.clearContext(); this.failureHandler.onAuthenticationFailure(request, response, ex); return; } // Eagerly save the security context to make it available for any possible // re-entrant requests which may occur before the current request // completes. SEC-1396. this.securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response); } else { // No security context or authentication present. Check for a session // timeout if (request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid()) { if (this.logger.isDebugEnabled()) { this.logger.debug(LogMessage.format("Request requested invalid session id %s", request.getRequestedSessionId())); } if (this.invalidSessionStrategy != null) { this.invalidSessionStrategy.onInvalidSessionDetected(request, response); return; } } } } chain.doFilter(request, response); }
- SecurityContextRepository 설정
- securityContext()의 securityContextRepository 메소드를 통해 커스텀 SecurityContextRepository 구현체를 등록함
- SecurityContextPersistenceFilter, SessionManagementFilter 모두 커스텀 SecurityContextRepository 구현체를 사용하게됨
@Override protected void configure(HttpSecurity http) throws Exception { http .securityContext() .securityContextRepository(securityContextRepository()) .and() // ... 생략 ... }
- JwtAuthenticationFilter 구현 VS. SecurityContextRepository 커스텀 구현
- JwtAuthenticationFilter
- HTTP 헤더에서 JWT 토큰을 추출하고, 검증하여 SecurityContext를 생성할 수 있음
- Security Filter 체인 상에서 어디에 위치하는지가 중요함
- SecurityContextPersistenceFilter 바로 뒤에 또는 UsernamePasswordAuthenticationFilter 필터 전후로 위치하면 적당함
- SecurityContextRepository 커스텀 구현
- 기본적으로 JwtAuthenticationFilter 구현과 유사함
- 그러나 SecurityContextRepository 인터페이스에 맞추어 부수적인 메소드 구현이 필요함
- saveContext, containsContext 메소드
- SecurityContextPersistenceFilter, SessionManagementFilter 2개의 필터에서 SecurityContextRepository 구현이 어떻게 사용되는지 잘 알고 있어야함
- SecurityContextRepository 인터페이스 커스텀 구현 방식이 추가적으로 고려할 내용이 많고, Spring Security 전반에 걸쳐 끼치는 영향이 더 큼
- 특히 SessionManagementFilter를 사용할 경우 SecurityContextRepository 메소드 구현 방법에 따라 적절한 설정이 필요함
sessionCreationPolicy가 STATELESS 이면서 (사실 JWT 토큰이 사용된다는 것 자체가 STATELESS임을 의미함) SecurityContextRepository의 containsContext 메소드가 false를 반환하는 경우, 불필요한 SessionFixationProtectionStrategy가 실행되지 않도록 NullAuthenticatedSessionStrategy 구현체를 설정하는 등 별도 처리가 필요하다.