Programming/Spring Boot

spring security + JWT 로그인 기능 파헤치기 - 2

Jan92 2021. 9. 22. 03:14
 

Spring Security + JWT 로그인 기능 파헤치기 - 1

로그인 기능은 거의 대부분의 애플리케이션에서 기본적으로 사용됩니다. 추가로 요즘은 웹이 아닌 모바일에서도 사용 가능하다는 장점과 Stateless 한 서버 구현을 위해 JWT를 사용하는 경우를 많

wildeveloperetrain.tistory.com

전 포스팅에서 이어지는 내용입니다. 참고 부탁드리겠습니다.

 

 

    public ResponseEntity<?> login(UserRequestDto.Login login) {

        // 1. Login ID/PW 를 기반으로 Authentication 객체 생성
        // 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
        UsernamePasswordAuthenticationToken authenticationToken = login.toAuthentication();

        // 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
        // authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);

        // TODO:: RefreshToken Redis 저장

        return response.success(tokenInfo, "로그인에 성공했습니다.", HttpStatus.OK);
    }

이전 포스팅에서는 첫 번째 과정인 ID, PW 기반 Authentication 객체 생성과 두 번째 과정인 authenticate() 실제 인증이 되는 절차를 알아봤습니다.

 

이번 포스팅에서는 JWT에 초점을 맞춰 인증 이후 JWT가 생성되는 코드와 이후 JWT를 가지고 api 요청을 했을 때 처리되는 과정을 살펴보고자 합니다.

 

 


 

 

@Slf4j
@Component
public class JwtTokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 30 * 60 * 1000L;              // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L;    // 7일

    private final Key key;

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

    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public UserResponseDto.TokenInfo generateToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return UserResponseDto.TokenInfo.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }
}

Service 단에서 Authentication 객체 인증 후, 인증된 객체를 가지고 JwtTokenProvider class의 generateToken() 메서드를 통해 Access Token, Refresh Token을 생성합니다. 

 

JWT를 사용하였을 때의 단점 중 하나가 토큰이 탈취되었을 때, 토큰을 탈취한 사람은 토큰의 유효시간이 끝날 때까지 토큰을 통해 자유롭게 인증을 할 수 있고 서버에서는 할 수 있는 것이 없다는 점입니다. 그래서 적용한 방법이 유효시간이 짧은 Access Token을 발급하고, Access Token이 만료되었을 때 재발급을 위한 Refresh Token을 발급하는 것입니다.

 

Access Token의 유효시간은 대략 30분 ~ 1시간, Refresh Token의 유효시간은 7일에서 많게는 30일까지 사용 환경과 개발 프로그램에 따라 다르게 적용됩니다. 또한 리프레시 토큰은 엑세스 토큰과 달리 Subject, Claim와 같은 회원 정보를 담고 있지 않습니다.

 

액세스 토큰 생성 시 claim에 넣는 권한 정보를 통해 Spring Security에서 해당하는 회원에게 접근 권한을 확인하게 됩니다.

 

 

accessToken, refreshToken

 

 


 

 

@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/api/v1/users/sign-up", "/api/v1/users/login", "/api/v1/users/authority").permitAll()
                .antMatchers("/api/v1/users/userTest").hasRole("USER")
                .antMatchers("/api/v1/users/adminTest").hasRole("ADMIN")
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
                // JwtAuthenticationFilter를 UsernamePasswordAuthentictaionFilter 전에 적용시킨다.
    }

    // 암호화에 필요한 PasswordEncoder Bean 등록
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

인증 과정을 살펴보기 전에 먼저 볼 시큐리티 설정입니다. WebSecutiryConfig 클래스는 시큐리티 설정을 위한 클래스로 WebSecurityConfigurerAdapter 클래스를 상속받습니다.

 

* @EnableWebSecutiry 어노테이션을 통해 Spring Security를 사용한다는 것을 선언합니다.

 

  • .httpBasic().disable() : rest api 이므로 basic auth 인증을 사용하지 않는다는 설정입니다.
  • .csrf().disable() : rest api 이므로 csrf 보안을 사용하지 않습니다.
  • .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : JWT를 사용하기 때문에 세션을 사용하지 않는다는 설정입니다.
  • .antMatchers().permitAll() : 모든 요청을 허가한다는 설정입니다.
  • .antMatchers().hasRole("USER") : USER 권한이 있어야 요청할 수 있다는 설정입니다.
  • .antMatchers().hasRole("ADMIN") : ADMIN 권한이 있어야 요청할 수 있다는 설정입니다.
  • .addFilterBefore(new JwtAUthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) : Jwt 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행하겠다는 설정입니다.

 

 


 

 

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_TYPE = "Bearer";

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // 1. Request Header 에서 JWT 토큰 추출
        String token = resolveToken((HttpServletRequest) request);

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

 

JWT 토큰을 Header에 담아 API 요청이 왔을 때 해당 토큰을 검사하고, 토큰에서 인증 정보를 가져오기 위해 생성하는 필터입니다.

과정을 보면 resolveToken() 메서드를 통해 HttpServletRequest 객체에서 Header 이름이 Authorization인 헤더를 가져옵니다. 그리고 해당 토큰이 'Bearer'로 시작되는지 확인 후 'Bearer' + ' ' (공백 1자리)를 잘라냅니다.

 

그런 다음 jwtTokenProvider class의 validateToken() 메서드를 통해 토큰의 유효성 검사를 진행합니다.

 

 

// 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
    try {
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        return true;
    } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
        log.info("Invalid JWT Token", e);
    } catch (ExpiredJwtException e) {
        log.info("Expired JWT Token", e);
    } catch (UnsupportedJwtException e) {
        log.info("Unsupported JWT Token", e);
    } catch (IllegalArgumentException e) {
        log.info("JWT claims string is empty.", e);
    }
    return false;
}

 

해당 메서드를 통해 토큰을 복호화하며 토큰 유효시간 또는 시그니처, 잘못된 형식의 토큰 등의 유효성을 체크합니다.

토큰 유효성 검사가 이상이 없다면 JwtTokenProvider의 getAuthentication() 메서드를 실행합니다.

 

 

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

 

앞에서 토큰의 유효성 검사를 마쳤다면, 해당 메서드에서는 토큰을 복호화하여 해당 정보로 UserDetails 객체를 만들어 리턴합니다.

* 여기서 User class는 UserDetails를 구현한 security 자체 클래스입니다.

 

 

// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);

// 2. validateToken 으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
    // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
    Authentication authentication = jwtTokenProvider.getAuthentication(token);
    SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);

 

다시 JwtAuthenticationFilter로 돌아와서 getAuthentication() 메서드를 통해 생성된 인증된 Authentication 객체는 SecurityContextHolder의 SecurityContext 안에 저장됩니다.

이후 나머지 filter chain이 동작하고 api 요청에 대한 응답을 하게 됩니다.

 

 

* 여기까지가 공부한 동작 원리이고 아래는 아직 해결하지 못한 의문사항입니다.

 

 

 


 

 

의문사항

 

JwtAuthenticationFilter는 최종적으로 SecurityContextHolder에 Authentication 객체를 저장합니다. JWT는 토큰 기반 인증으로 Session을 사용하지 않기 위해 사용하는데 SecutiryContextHolder는 HttpSession 기반으로 동작한다고 합니다.

 

여기는 FilterChain 중 동작하는 SecurityContextPersistenceFilter나 SecurityContextHolder class의 clearContext() 메서드가 관련이 있을 것 같은데 해당 내용은 조금 더 찾아보고 공부해야 할 것 같습니다.

 

 

 

 

Redis 추가

 

Security + JWT + Redis 로그인 기능 구현 (최종)

이전 Spring Security + JWT 로그인 구현에서 마지막으로 Refresh Token 저장을 위한 Redis를 추가하고, 토큰 재발급 기능을 추가하여 로그인 기능을 완성하였습니다. Spring Security + JWT 로그인 기능 파헤치..

wildeveloperetrain.tistory.com

 

 

 

함께 보면 좋은 자료

 

Spring Security 시큐리티 동작 원리 이해하기 - 1

스프링 시큐리티 (Spring Security)는 스프링 기반 어플리케이션의 보안(인증과 권한, 인가)을 담당하는 스프링 하위 프레임워크입니다. 보안과 관련해서 체계적으로 많은 옵션들을 제공해주기 때문

wildeveloperetrain.tistory.com

 

 

JWT 토큰 기반 인증 시스템 (JSON Web Token)

Spring Boot + Security + JWT + Redis 를 기본으로한 RESTful API를 구현하기로 계획하며 토큰 기반 인증 시스템 JWT에 대해서 다시 한번 정리합니다. 토큰 기반 인증 시스템이란, 먼저 웹 보안은 요청하는 사

wildeveloperetrain.tistory.com

 

 

GitHub

 

GitHub - JianChoi-Kor/Login: Login (Spring Security, JWT, Redis, JPA )

Login (Spring Security, JWT, Redis, JPA ). Contribute to JianChoi-Kor/Login development by creating an account on GitHub.

github.com

 

 

참고 자료

 

Spring Security 와 JWT 겉핥기

Introduction 이 글에서는 Spring Boot + JWT + Security 를 사용해서 회원가입/로그인 로직을 구현했습니다. JWT 와 Spring Security 코드는 인프런 Spring Boot JWT Tutorial (정은구) 강의를 수강하면서 만들고..

bcp0109.tistory.com

 

 

SPRING SECURITY + JWT 회원가입, 로그인 기능 구현

이전에 서블릿 보안과 관련된 포스트(링크)를 작성했던 적이 있습니다. 서블릿 기반의 웹 애플리케이션에서 인증과 인가 과정을 간단하게 설명했습니다. 스프링에서는 마찬가지로 이런 인증과

webfirewood.tistory.com