업데이트:

4 분 소요

구현2

JWT를 더욱 완벽히 구현하기 위해서는 인증 API와 함께 추가적인 리팩터링이 필요하다.

다음과 같은 작업이 진행된다.

  • Jwt 전용 Authentication 인터페이스 구현체 추가
  • JwtAuthenticationProvider 구현과 UserDetailsService에 대한 의존 제거
  • JwtAuthenticationFilter 대체 구현

Authentication 인터페이스 구현체

JWT에 인증 처리를 명확히 하기 위해 Authentication 인터페이스의 구현체로 JwtAuthenticationToken을 추가와 동시에 UserDetails.User 타입을 JwtAuthentication으로 교체할 필요가 있다.

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;
  }
}

JwtAuthenticationProvider 구현, UserDetailsService에 대한 의존 제거

Jwt가 아닌 세션을 이용할 때는 UserPasswordAuthenticationToken 타입을 처리하기 위해 AuthenticationProvider의 구현체 DaoAuthenticationProvider를 이용했다.

하지만 JwtAuthenticationToken의 경우 이를 처리할 타입이 없기 때문에 AuthenticationProvider 인터페이스인 구현체인 JwtAuthenticationProvider 구현이 필요하다.

JwtAuthenticationProvider가 가져야 하는 기능은 다음과 같다.

  • JwtAuthenticaitonToken에 대한 처리
  • UserDetailsService에 의존하지 않는 UserService 클래스를 통한 로그인 처리와 Jwt토큰 생성
  • 인증 완료 사용자의 JwtAuthenticationToken 반환
public class JwtAuthenticationProvider implements AuthenticationProvider {

  private final Jwt jwt;

  private final UserService userService;

  public JwtAuthenticationProvider(Jwt jwt, UserService userService) {
    this.jwt = jwt;
    this.userService = userService;
  }

  @Override
  public boolean supports(Class<?> authentication) {
    return isAssignable(JwtAuthenticationToken.class, 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);
    }
  }

  private String getToken(String username, List<GrantedAuthority> authorities) {
    String[] roles = authorities.stream()
      .map(GrantedAuthority::getAuthority)
      .toArray(String[]::new);
    return jwt.sign(Jwt.Claims.from(username, roles));
  }

}
@Service
public class UserService {

  private final PasswordEncoder passwordEncoder;

  private final UserRepository userRepository;

  public UserService(PasswordEncoder passwordEncoder, UserRepository userRepository) {
    this.passwordEncoder = passwordEncoder;
    this.userRepository = userRepository;
  }

  @Transactional(readOnly = true)
  public User login(String principal, String credentials) {
    checkArgument(isNotEmpty(principal), "principal must be provided.");
    checkArgument(isNotEmpty(credentials), "credentials must be provided.");

    User user = userRepository.findByLoginId(principal)
      .orElseThrow(() -> new UsernameNotFoundException("Could not found user for " + principal));
    user.checkPassword(passwordEncoder, credentials);
    return user;
  }

  @Transactional(readOnly = true)
  public Optional<User> findByLoginId(String loginId) {
    checkArgument(isNotEmpty(loginId), "loginId must be provided.");
    return  userRepository.findByLoginId(loginId);
  }

}

로그인 API, 정보 조회 API

로그인 API

로그인에 대한 처리는 AuthenticationManager를 통해서 한다.

JwtAuthenticationToken 객체를 AuthenticationManager를 통해 처리하기 위해서는 JwtAuthenticationProvider가 AuthenticationManager에 추가 되어 있어야한다.

이를 위해 JwtAuthenticationProvider는 Bean으로 등록되어 있어야하고 반대로 AuthenticatioqnManager는 AuthenticaitonManagerBuilder를 통해 JwtAuthenticationProvider 객체가 추가되어 있어야한다.
마지막으로 AuthenticationManager 객체는 authenticationManagerBean 메서드 호출을 통해 Bean으로 등록되어야 한다.


public class SecurityConfigure{

	...

	@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();
	}
...
}

AuthenticationManager와 JwtAuthenticationProvider에 구현이 완료되었다면 필터 또한 이에 맞게 리팩터링 되어야 한다.

JwtAuthenticationProvider가 UsernamePasswordAuthenticationToken이 아닌 JwtAuthenticationToken을 이용하기 위해서는 principal 필드는 JwtAuthentication 객체로, details 필드는 WebAuthenticationDetails 객체로 교체되어야 한다.

이후 SecurityContextHHolder.getcontext().setAuthentication 메서드를 통해 JwtAuthneticationToken이 객체 참조를 전달해야한다.

내 정보 조회 API

정보를 조회하는 API를 구현하기 위해서는 @AuthenticationPrincipal을 이용할 수 있다.

  • @AuthenticationPrincipal

    Authentication 구현체에서 principal 필드를 추출

    이 어노테이션은 아래와 같이 컨트롤러에서 사용된다.

      @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));
        }
    

JwtAuthenticationFilter 대체 구현

본래 JwtAuthenticationFilter의 핵심 기능인 Http 요청헤더에서 토큰을 확인하고 검증한 뒤 Security Context를 생성하는 기능은 Spring Security 기본 필터인 SecurityContextPersistenceFilter와 유사하다.

SecurityContextPersistenceFilter는 SecurityContextRepository에서 SecurityContext를 읽어와 Http 요청에서 필요한 데이터를 이용하는데, 이를 Jwt로 교체해 적용할 수 있다.

즉 SecurityContextRepository와 같은 역할을 하는 대신 데이터로 Jwt 를 이용하는 Repository를 구현한다면 , SecurityContextPersistenceFilter가 이를 이용해 JwtAuthenticationFilter의 기능을 대신할 수 있을 것이다.

public class JwtSecurityContextRepository implements SecurityContextRepository {

  private final Logger log = LoggerFactory.getLogger(getClass());

  private final String headerKey;

  private final Jwt jwt;

  public JwtSecurityContextRepository(String headerKey, Jwt jwt) {
    this.headerKey = headerKey;
    this.jwt = jwt;
  }

  @Override
  public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
    HttpServletRequest request = requestResponseHolder.getRequest();
    SecurityContext context = SecurityContextHolder.createEmptyContext();
    JwtAuthenticationToken authentication = authenticate(request);
    if (authentication != null) {
      context.setAuthentication(authentication);
    }
    return context;
  }

  @Override
  public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
    /*no-op*/
  }

  @Override
  public boolean containsContext(HttpServletRequest request) {
    JwtAuthenticationToken authentication = authenticate(request);
    return authentication != null;
  }

  private JwtAuthenticationToken authenticate(HttpServletRequest request) {
    String token = getToken(request);
    if (isNotEmpty(token)) {
      try {
        Jwt.Claims claims = jwt.verify(token);
        log.debug("Jwt parse result: {}", claims);

        String username = claims.username;
        List<GrantedAuthority> authorities = getAuthorities(claims);
        if (isNotEmpty(username) && authorities.size() > 0) {
          JwtAuthenticationToken authentication
            = new JwtAuthenticationToken(new JwtAuthentication(token, username), null, authorities);
          authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
          return authentication;
        }
      } catch (Exception e) {
        log.warn("Jwt processing failed: {}", e.getMessage());
      }
    }
    return null;
  }

  private String getToken(HttpServletRequest request) {
    String token = request.getHeader(headerKey);
    if (isNotEmpty(token)) {
      log.debug("Jwt authorization api detected: {}", token);
      try {
        return URLDecoder.decode(token, "UTF-8");
      } catch (UnsupportedEncodingException e) {
        log.error(e.getMessage(), e);
      }
    }
    return null;
  }

  private List<GrantedAuthority> getAuthorities(Jwt.Claims claims) {
    String[] roles = claims.roles;
    return (roles == null || roles.length == 0)
      ? emptyList()
      : Arrays.stream(roles).map(SimpleGrantedAuthority::new).collect(toList());
  }

}

이전에 JwtFilter를 이용했던것과 같이 SecurityConfigure.class에서 필터를 적용해야 한다.

@Override
protected void configure(HttpSecurity http) throws Exception {
  http
    .securityContext()
	    .securityContextRepository(securityContextRepository())
      .and()
	...
}

SecurityContextPersistenceFilter의 SecurityContextRepository를 커스텀 구현 해 JWT를 처리하게 하는 것은 상속으로 인해 간단해보이지만 고려해야 할 점이 많다.

의외로 JwtAuthenticationFilter와 구현이 유사했던 점과 함께 SecurityContextRepository 인터페이스에 맞추어 구현해야할 부가적인 메서드 또한 존재했다.

댓글남기기