[Spring Security, JWT, Redis] JwtTokenProvider, SecurityConfig 구현

3Beom's 개발 블로그·2023년 8월 6일
2

프로젝트에서 Spring Security + JWT + Redis 방식으로 인증/인가 로직을 구현하였고, 전체 과정을 기록으로 남겨두려 한다.


1. Spring Security + JWT 동작 방식

  • Spring Security와 JWT 방식을 활용하는 경우, Spring Security의 기본적인 동작 방식(세션-쿠키)과는 차이가 있다.
  • Spring Security + JWT 방식의 경우, 인증 과정 중 AuthenticationFilter에서 Http Request의 Header에 담긴 Access Token을 통해 인증을 수행하게 된다.
  • Access Token이 유효할 경우 인증된 것으로 간주하고, 해당 Token의 Payload에 저장된 정보로 Authentication 객체를 생성한 후 SecurityContext에 저장하게 된다.
    • Access Token의 Payload에 다음 정보가 저장된다.
      • 사용자의 아이디
      • 사용자의 권한 정보

2. 기본 설정

(gradle 기준)

2-1. 의존성 설정

[2-1-1. Spring Security 의존성 설정]

implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

[2-1-2. JWT 의존성 설정]

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

2-2. JWT Token Provider 클래스 구현

[2-2-1. JwtTokenProvider 클래스 생성]

  • 먼저 JWT 생성, 유효성 검사 등의 로직을 포함하고 있는 JWT Token Provider 클래스를 구현해야 한다.
  • JwtTokenProvider 클래스를 생성한다.
@Slf4j
@Component
public class JwtTokenProvider {

}

[2-2-2. JWT 생성 로직 구현]

<Token 저장 클래스 생성>

  • Access Token과 Refresh Token을 담을 클래스를 생성한다.
@Builder
@Data
@AllArgsConstructor
public class TokenInfo {

    private String grantType;
    private String accessToken;
    private String refreshToken;

}
  • grantType은 JWT에 대한 인증 타입이다. 본 과정에서는 Bearer 를 활용하며, 이후 Http Request의 헤더에 Token을 담을 때 prefix로 설정된다.

<Secret Key 등록>

  • JWT 생성 과정에 필요한 Secret Key를 등록해야 한다.
  • application.yml 파일에 넣어두고 가져다 쓴다.
  • Secret Key는 외부에 노출되면 안되므로 후에 yml 파일 분리를 통해 Secret Key가 저장된 파일은 숨긴다.
# application.yml

...

jwt:
  secret: XUKemfiZfrEWLtykdkjfeliwjfd59YLt5XXXkAdqZu33
  • 암호화 과정에서 HS256 알고리즘을 활용할 예정이므로 Secret Key의 길이가 256비트보다 커야한다.
  • JwtTokenProvider 클래스에서 해당 Secret Key를 받아온다.
// JwtTokenProvider

...

	private final Key key;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

...

<JWT 생성 메서드 - 로그인, 회원가입, 토큰 재발급 과정에서 활용>

// JwtTokenProvider

...
    // AccessToken 유효기간 설정 : 30분
    private final long THIRTY_MINUTES = 1000 * 60 * 30;
    // RefreshToken 유효기간 설정 : 1주
    private final long ONE_WEEK = 1000 * 60 * 60 * 24 * 7;

...

    public TokenInfo generateToken(Collection<? extends GrantedAuthority> authorityInfo,
        String id) {
        // 사용자의 권한 정보들을 모아 문자열로 만든다.
        String authorities = authorityInfo.stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // AccessToken 생성 과정
        Date accessTokenExpiresIn = new Date(now + THIRTY_MINUTES);
        String accessToken = Jwts.builder()
            .setSubject(id)
            .claim("auth", authorities)
            .setExpiration(accessTokenExpiresIn)
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();

        // RefreshToken 생성 과정
        Date refreshTokenExpiresIn = new Date(now + ONE_WEEK);
        String refreshToken = Jwts.builder()
            .setExpiration(refreshTokenExpiresIn)
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();

        return TokenInfo.builder()
            .grantType("Bearer")
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();
    }

...
  • 위 코드에서는 편의상 Access Token과 Refresh Token의 유효기간을 적어뒀지만, 해당 정보도 yml 파일에 숨기고 @Value 어노테이션으로 받아오는 것이 안전하다.
  • 앞서 언급한 것과 같이 Access Token의 Payload에 사용자의 아이디와 권한 정보가 저장된다.
    • 이에 따라 JWT 생성 메서드의 파라미터로 전달받는다.
  • Refresh Token은 인증이 목적이 아닌, Access Token 재발급이 목적이므로 Payload에 아무 정보도 저장하지 않는다.
  • 생성된 Token은 앞서 생성한 TokenInfo 객체에 담아 반환한다.

[2-2-3. AuthenticationFilter에서 인증 과정에 활용되는 메서드 구현]

<Authentication 객체 반환 메서드 - AuthenticationFilter에서 활용>

// JwtTokenProvider

...

		// Access Token에 들어있는 정보를 꺼내 Authentication 객체를 생성 후 반환한다.
    public Authentication getAuthentication(String accessToken) {
        // 토큰의 Payload에 저장된 Claim들을 추출한다.
        Claims claims = parseClaims(accessToken);

        if (claims.get("auth") == null) {
            // 권한 정보 없는 토큰
            throw new InvalidTokenException(INVALID_TOKEN.getMessage());
        }

        // Claim에서 권한 정보를 추출한다.
        Collection<? extends GrantedAuthority> authorities = Arrays
            .stream(claims.get("auth").toString().split(","))
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());

        // Claim에 저장된 사용자 아이디를 통해 UserDetails 객체를 생성한다.
        UserDetails principal = new User(claims.getSubject(), "", authorities);

				 // Authentication 객체를 생성하여 반환한다.
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(accessToken)
                .getBody();
        } catch (ExpiredJwtException e) {
            throw new ExpiredTokenException(EXPIRED_TOKEN.getMessage());
        }

    }
...
  • Access Token의 Payload에 저장된 사용자의 아이디와 권한 정보를 토대로 Authentication 객체를 만들어 반환하는 메서드이다.
  • AuthenticationFilter에서 해당 메서드를 통해 생성된 Authentication 객체를 SecurityContext에 저장한다.

<유효성 검증 메서드 - AuthenticationFilter에서 활용>

// JwtTokenProvider

		// 토큰 검증 메서드
    public boolean validateToken(String token) throws TokenException {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            throw new InvalidTokenException(INVALID_TOKEN.getMessage());
        } catch (ExpiredJwtException e) {
            throw new ExpiredTokenException(EXPIRED_TOKEN.getMessage());
        }
    }
  • Token을 parsing 하는 과정에서 예외가 발생하지 않으면 유효한 토큰으로 간주한다.
  • TokenException, InvalidTokenException, ExpiredTokenException 클래스와 같이 적절한 형태로 예외를 처리해준다.
  • 해당 예외들은 후에 AuthenticationFilter에서 catch하도록 설정한다.

<Access Token 추출 메서드 - AuthenticationFilter에서 활용>

    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");

        log.debug("bearertoken : {}", bearerToken);
        if (StringUtils.hasText(bearerToken)) {
            if (bearerToken.startsWith("Bearer") && bearerToken.length() > 7) {
                int tokenStartIndex = 7;
                return bearerToken.substring(tokenStartIndex);
            }
            throw new MalformedHeaderException(MALFORMED_HEADER.getMessage());
        }

        return bearerToken;
    }
  • Http Request의 Header로부터 Access Token을 추출하는 메서드이다.

  • Access Token은 “Authorization” 필드에 담겨서 전달되도록 하고, Token 앞에 “Bearer ” 문자열을 prefix로 붙이도록 한다.

  • JwtTokenProvider 클래스 전체 코드

    // JwtTokenProvider
    
    @Slf4j
    @Component
    public class JwtTokenProvider {
    
        private final Key key;
        private final long THIRTY_MINUTES = 1000 * 60 * 30;
        private final long ONE_WEEK = 1000 * 60 * 60 * 24 * 7;
    
        public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
            byte[] keyBytes = Decoders.BASE64.decode(secretKey);
            this.key = Keys.hmacShaKeyFor(keyBytes);
        }
    
        public TokenInfo generateToken(Collection<? extends GrantedAuthority> authorityInfo,
            String id) {
            // 사용자의 권한 정보들을 모아 문자열로 만든다.
            String authorities = authorityInfo.stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));
    
            long now = (new Date()).getTime();
    
            // AccessToken 생성 과정
            Date accessTokenExpiresIn = new Date(now + THIRTY_MINUTES);
            String accessToken = Jwts.builder()
                .setSubject(id)
                .claim("auth", authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    
            // RefreshToken 생성 과정
            Date refreshTokenExpiresIn = new Date(now + ONE_WEEK);
            String refreshToken = Jwts.builder()
                .setExpiration(refreshTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    
            return TokenInfo.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
        }
    
    		// Access Token에 들어있는 정보를 꺼내 Authentication 객체를 생성 후 반환한다.
        public Authentication getAuthentication(String accessToken) {
            // 토큰의 Payload에 저장된 Claim들을 추출한다.
            Claims claims = parseClaims(accessToken);
    
            if (claims.get("auth") == null) {
                // 권한 정보 없는 토큰
                throw new InvalidTokenException(INVALID_TOKEN.getMessage());
            }
    
            // Claim에서 권한 정보를 추출한다.
            Collection<? extends GrantedAuthority> authorities = Arrays
                .stream(claims.get("auth").toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    
            // Claim에 저장된 사용자 아이디를 통해 UserDetails 객체를 생성한다.
            UserDetails principal = new User(claims.getSubject(), "", authorities);
    
    				 // Authentication 객체를 생성하여 반환한다.
            return new UsernamePasswordAuthenticationToken(principal, "", authorities);
        }
    
        private Claims parseClaims(String accessToken) {
            try {
                return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody();
            } catch (ExpiredJwtException e) {
                throw new ExpiredTokenException(EXPIRED_TOKEN.getMessage());
            }
    
        }
    
        // 토큰 검증 메서드
        public boolean validateToken(String token) throws TokenException {
            try {
                Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
                return true;
            } catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
                throw new InvalidTokenException(INVALID_TOKEN.getMessage());
            } catch (ExpiredJwtException e) {
                throw new ExpiredTokenException(EXPIRED_TOKEN.getMessage());
            }
        }
    
        public String resolveToken(HttpServletRequest request) {
            String bearerToken = request.getHeader("Authorization");
    
            log.debug("bearertoken : {}", bearerToken);
            if (StringUtils.hasText(bearerToken)) {
                if (bearerToken.startsWith("Bearer") && bearerToken.length() > 7) {
                    int tokenStartIndex = 7;
                    return bearerToken.substring(tokenStartIndex);
                }
                throw new MalformedHeaderException(MALFORMED_HEADER.getMessage());
            }
    
            return bearerToken;
        }
    }

2-3. SecurityConfig 클래스 생성

  • Spring Security의 설정을 수행할 수 있는 Configuration 클래스를 생성한다.
// SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .httpBasic().disable()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()

            .authorizeRequests()
            .antMatchers(HttpMethod.GET, "/~~~").permitAll()
            .antMatchers("/~~~").authenticated()
            .regexMatchers(HttpMethod.POST, "/~~~").authenticated()
            .anyRequest().authenticated();

        return http.build();
    }
}
  • @Configuration, @EnableWebSecurity 어노테이션을 부여한다.
  • JWT 방식이므로 STATELESS로 설정한다.
  • authorizeRequests() 이후에 url 별로 접근 권한을 설정할 수 있다.
    • antMatchers()regexMatchers() 를 활용하여 url을 표현할 수 있다.
    • permitAll() 을 통해 모든 접근을 허용할 수 있으며 authenticated() 를 통해 인증된 사용자만 접근할 수 있도록 제한할 수 있다.
      • 이 외에도 hasAuthority() 등을 통해 특정 권한에 대한 제한을 부여할 수 있다.
  • 위 코드는 기본적인 설정이며, 이후 filter, 예외처리 등에 대한 설정이 추가된다.
  • 또한, PasswordEncoder Bean을 등록해주어야 한다.
    • PasswordEncoderFactories.createDelegatingPasswordEncoder()
      public static PasswordEncoder createDelegatingPasswordEncoder() {
      		String encodingId = "bcrypt";
      		Map<String, PasswordEncoder> encoders = new HashMap<>();
      		encoders.put(encodingId, new BCryptPasswordEncoder());
      		encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
      		encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
      		encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
      		encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
      		encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
      		encoders.put("scrypt", new SCryptPasswordEncoder());
      		encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
      		encoders.put("SHA-256",
      				new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
      		encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
      		encoders.put("argon2", new Argon2PasswordEncoder());
      		return new DelegatingPasswordEncoder(encodingId, encoders);
      	}
      • 다양한 PasswordEncoder들을 제공해주는 것을 확인할 수 있다.

      • Spring Security에서는 기본적으로 BCrypt Encoder가 활용된다.

        (후에 패스워드를 encoding하여 저장할 때 앞에 {bcrypt} 가 붙는 것을 확인할 수 있다!)

profile
경험과 기록으로 성장하기

1개의 댓글

comment-user-thumbnail
2023년 8월 6일

좋은 글 감사합니다. 자주 방문할게요 :)

답글 달기