Spring Security 인증구조에 맞게 JWT인증 구현하기

점돌이·2024년 1월 18일
0

구글에 검색하면 나오는 JWT 인증구조

먼저 이 방법이 틀렸다고 주장하는것은 아니다.
구조가 간단하다는 장점이 있고 왜 이런 구조를 가지게 됐는지도 이해한다.
다만 개인적으로 스프링시큐리티의 기본적인 인증 절차가 아니어서 그에 맞춰 구현하고 싶었다.

구글에 스프링시큐리티 JWT인증 구현을 검색해보면 이런 구조를 하고있다.

  1. JwtProvider가 스프링시큐리티에서 말하는 Provider가 아닌 Jwt 생성 및 검증 관련 기능을 모아놓은 클래스가 인증 객체를 반환
  2. JwtAuthenticationFilter가 AuthenticationManager에게 위임하는게 아닌 위의 JwtProvider를 호출해 JWT검증을 진행 후 SecurityContextHolder 등록

일단 필자도 과거에 미니 프로젝트를 진행할 때 위의 방식을 따라 다음과 같이 구현했었다.


//JWT 인증 필터
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;
    private AuthenticationEntryPoint authenticationEntryPoint = new AuthenticationExceptionHandler();


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            Optional<String> token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
            if (token.isPresent() && jwtTokenProvider.validateToken(token.get())) {
                Authentication authentication = jwtTokenProvider.getAuthentication(token.get());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            filterChain.doFilter(request, response);
        } catch (ExpiredTokenException ex) {
            authenticationEntryPoint.commence(request, response, ex);
        }
    }
}

//JWT Provider
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
    private String secretKey = "scrkey";
    private final static long ACCESS_VALID_TIME = 30 * 60 * 1000L; // 30m
    private final static long REFRESH_VALID_TIME = 1000L * 60 * 60 * 24;  // 24h
    private final CustomUserDetailService userDetailsService;
    private final RedisService redisService;
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public String createToken(String userId, List<String> roles, long validTime) {
        Claims claims = Jwts.claims().setSubject(userId);
        claims.put("roles", roles);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + validTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public String createAccessToken(String userId, List<String> roles) {
        return this.createToken(userId, roles, ACCESS_VALID_TIME);
    }

    public String createRefreshToken(String userId, List<String> roles) {
        String token = this.createToken(userId, roles, REFRESH_VALID_TIME);
        redisService.setValues(userId, token, Duration.ofMillis(REFRESH_VALID_TIME));
        return token;
    }

//인증객체를 반환하는 모습
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserId(token));
        userDetails.getAuthorities().stream().forEach(a -> log.info(a.getAuthority()));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUserId(String token) {
        try {
            String userId = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token)
                    .getBody()
                    .getSubject();
            return userId;
        } catch (ExpiredJwtException ex) {
            ex.printStackTrace();
            throw new ExpiredTokenException();
        }
    }

    public Optional<String> resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return Optional.of(bearerToken.substring(7));
        } else {
            throw new ExpiredTokenException();
        }
    }

    public boolean validateToken(String jwtToken) {
        try {
            if (redisService.getValues(jwtToken) != null) {
                return false;
            }
            Jws<Claims> claims = Jwts
                    .parser().
                    setSigningKey(secretKey)
                    .parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (ExpiredJwtException e) {
            log.info("validationToken 에러");
            log.info(jwtToken);
            e.printStackTrace();
            return false;
        }
    }

    public Long getExpiredTime(String token) {
        Date expiration = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody()
                .getExpiration();
        Long expiredTime = expiration.getTime() - new Date().getTime();
        return expiredTime;
    }
}

위의 코드를 리팩토링해보겠다.

스프링시큐리티 인증구조 살펴보기

코드 수정에 앞서 스프링 시큐리티의 인증구조를 간단하게 살펴보자.

  1. 인증필터가 인증객체에 요청된 인증정보를 넣고 인증매니저에게 위임한다.
  2. 인증매니저는 해당 인증 객체를 지원하는 프로바이더를 순회하며 찾고 위임한다.
  3. 프로바이더는 검증 후 인증객체를 반환한다.
  4. 필터에서 반환받은 인증객체를 시큐리티 컨텍스트 홀더에 저장한다.

인증구조를 확인하고 나면 필요한것은 아래와 같다.

  1. JWT인증 정보를 담을 인증객체(AuthenticationToken)
  2. 인증매니저에 등록할 프로바이더(AuthenticationProvider)

정리 됐으니 리팩토링을 진행해보자

JWT인증구조 리팩토링

인증객체 생성

인증객체 구현 방법은 여러가지가 있겠지만 기본적으로 제공되는 클래스를 참고하는것이 제일 쉽고 이해하기도 좋다.
UsernamePasswordAuthenticationToken을 참고해서 구현해 보겠다.
코드를 한번 살펴보자.

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	private final Object principal;

	private Object credentials;

	/**
	 * This constructor can be safely used by any code that wishes to create a
	 * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
	 * will return <code>false</code>.
	 *
	 */
	public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
		super(null);
		this.principal = principal;
		this.credentials = credentials;
		setAuthenticated(false);
	}

	/**
	 * This constructor should only be used by <code>AuthenticationManager</code> or
	 * <code>AuthenticationProvider</code> implementations that are satisfied with
	 * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
	 * authentication token.
	 * @param principal
	 * @param credentials
	 * @param authorities
	 */
	public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
			Collection<? extends GrantedAuthority> authorities) {
		super(authorities);
		this.principal = principal;
		this.credentials = credentials;
		super.setAuthenticated(true); // must use super, as we override
	}

	@Override
	public Object getCredentials() {
		return this.credentials;
	}

	@Override
	public Object getPrincipal() {
		return this.principal;
	}

	@Override
	public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
		Assert.isTrue(!isAuthenticated,
				"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
		super.setAuthenticated(false);
	}

	@Override
	public void eraseCredentials() {
		super.eraseCredentials();
		this.credentials = null;
	}

}

눈여겨 볼것은 생성자 로직이다.
최초 로그인에 쓰이는 UsernamePasswordAuthenticationToken애서는 username이 principal, password가 credentials가 된다.
GrantedAuthority인자가 없는 생성자는 인증을 요청할 때 쓰인다.
또 인증이 성공했을 때는 부모의 setAuthenticated 메소드를 호출해야한다.
즉 2개의 생성자를 나눠 하나는 인증요청용 하나는 인증완료용으로 쓴다.
principal, credentials 둘 다 JWT를 넣어주는것으로 구현을 하려한다.
어차피 JWT만을 가지고 인증하니 credentials를 공백만 입력해줘도 상관없어 보인다.

public class JwtAuthenticationToken extends AbstractAuthenticationToken {
 private final Object principal;

    private Object credentials;


    public JwtAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(false);
    }


    public JwtAuthenticationToken(Object principal, Object credentials,
                                  Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }

}

인증 프로바이더 구현

인증 프로바이더 구현은 비교적 간단하다.
AuthenticationProvider을 상속받아 원하는 방식으로 인증객체의 검증방식을 구현하면 된다.
구현전에 AuthenticationProvider의 메소드에 대해 설명하자면
authenticate는 인증 검증과정을 구현하는 메소드이고
supports는 필터에서 인증 매니저에게 위임하고 인증 매니저는 프로바이더에게 위임하는데 이 때 해당 인증 객체가 어떤 프로바이더가 처리가능한지를 알려주는 메소드이다.
필자의 경우 Jwt를 검증하던 JwtTokenProvider 클래스 주입받아 처리하는 방식을 택했다.
그리고 역할에 맡게 클래스 이름을 JwtService로 바꿨다.

@RequiredArgsConstructor
public class JwtAuthenticationProvider implements AuthenticationProvider {
    private final JwtService jwtService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String jwt = (String) authentication.getPrincipal();
        List<SimpleGrantedAuthority> roles = jwtService.getRoles(jwt);
        Authentication authenticated = new JwtAuthenticationToken(jwt, "", roles);
        return authenticated;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return JwtAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

JwtTokenProvider 클래스 코드를 자세히 보면 JWT에 권한정보를 넣어놨음에도 DB에서 권한정보를 받아오는 코드를 확인할 수 있다.
얼마나 생각없이 따라치기 급급했는지 알 수 있다.

 public String createToken(String userId, List<String> roles, long validTime) {
        Claims claims = Jwts.claims().setSubject(userId);
        claims.put("roles", roles); // 자격을 Jwt 저장
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + validTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }
    
  public Authentication getAuthentication(String token) {//DB에서 자격을 조회
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserId(token)); 
        userDetails.getAuthorities().stream().forEach(a -> log.info(a.getAuthority()));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }    
    

그래서 다음과 같이 Jwt에서 권한만 가져오는 메소드를 추가하고 DB에서 권한을 가져오는 인증부분은 삭제했다.

    public List<SimpleGrantedAuthority> getRoles(String token) {
        try {
            Object roles = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token)
                    .getBody()
                    .get("roles");
            List<String> roleList = (List<String>) roles;
            List<SimpleGrantedAuthority> authorities = roleList.stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
            return authorities;
        } catch (ExpiredJwtException ex) {
            ex.printStackTrace();
            throw new ExpiredTokenException();
        }
    }

인증필터 구현

인증필터는 기존 구현에서 바뀌는점은 다음과 같다.

  1. 인증 매니저 주입
  2. JwtService대신 인증 객체 생성 후 인증 프로바이더를 통해 인증받기

OncePerRequestFilter를 상속해 구현했는데 해당 필터는 요청당 1번의 실행이 보장되는 필터이다.
OncePerRequestFilter의 shouldNotFilter라는 메소드는 특정 URL에서 필터가 작동하지 않도록 설정할 수 있다.

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtService jwtService;
    private AuthenticationEntryPoint authenticationEntryPoint = new AuthenticationExceptionHandler();
    private final AuthenticationManager authenticationManager;


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            Optional<String> token = jwtService.resolveToken((HttpServletRequest) request);
            if (token.isPresent() && jwtService.validateToken(token.get())) {
                JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(token.get(), ""); //인증요청객체 생성
//                Authentication authentication = jwtService.getAuthentication(token.get());
                Authentication authentication = authenticationManager.authenticate(jwtAuthenticationToken); //프로바이더에 위임
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            filterChain.doFilter(request, response);
        } catch (ExpiredTokenException ex) {
            authenticationEntryPoint.commence(request, response, ex);
        }
    }

인증 프로바이더 빈 등록


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
    private final JwtService jwtService;
	
    @Bean
    protected SecurityFilterChain configure(HttpSecurity http) throws Exception {
        http
             .addFilterBefore(new JwtAuthenticationFilter(jwtService, authenticationManager()), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
    
    @Bean
    public AuthenticationManager authenticationManager() {
        JwtAuthenticationProvider jwtAuthenticationProvider = new JwtAuthenticationProvider(jwtService);
        return new ProviderManager(jwtAuthenticationProvider);
    }


}

인증 프로바이더를 등록하는 방법은 인증 매니저 빌더를 빈으로 생성 후 Provider를 추가하거나 HttpSecurity빌더 내에 authenticationProvider()로 추가하는 방법도 있다.
간단하게 빈등록 후 필터에 주입하는 방법으로 했다.

마무리

과거에 시큐리티 이해가 완전하지 않은 상태에서 짰던 코드를 리팩터링해봤다.
처음에는 뭐가 뭔지 몰랐지만 막상 공부하고 나면 정말 인증 시스템을 편리하게 구현할 수 있도록 만들어진 프레임워크임에 감탄하게된다.
누군가에게 스프링 시큐리티 구조 이해에 도움이 되었으면한다.

profile
감사합니다.

0개의 댓글