GrowMe

JWT를 SpringBoot 프로젝트에 적용하기(2) - Refresh Token 적용 본문

Security

JWT를 SpringBoot 프로젝트에 적용하기(2) - Refresh Token 적용

오늘도 타는중 2022. 9. 19. 01:16
JWT(Json Web Token) 적용 - Refresh Token
# 들어가기 전에
#
 
Refresh Token
# Refresh 토큰을 적용한 JWT 로그인 인증 절차

# JWT - Refresh Token 적용하기
# 동작 테스트

*들어가기 전에

https://grow-myself.tistory.com/37

 

[Spring] 스프링 시큐리티의 개념과 구조

Spring Security # Spring Security 개념 # 필터 # Spring Security 특징 # Spring Security 구조 # 필터 별 기능 *스프링 시큐리티란? 스프링 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 ..

grow-myself.tistory.com

https://grow-myself.tistory.com/38

 

[Spring] 스프링부트에서의 스프링 시큐리티 기본 세팅

Spring Security 기본 세팅 # Gradle 추가 및 SecurityConfig 파일 생성 # 예외처리 # 로그인 화면을 커스텀 페이지로 # id, pw 인증 처리 # 비밀번호 암호화 방식 커스텀 *Gradle 추가 / SecurityConfig - bu..

grow-myself.tistory.com

https://grow-myself.tistory.com/45

 

JWT를 SpringBoot 프로젝트에 적용하기

JWT(JSON Web Token) # JWT란 # JWT의 구조 # 인증 절차 # JWT 적용하기(인증 처리) # JWT 적용하기(인가 처리) *JWT란? JSON을 기반으로, 인증에 필요한 정보들을 암호화 시킨 토큰이다. 토큰이란? 마치 일..

grow-myself.tistory.com

본 포스팅 내용을 이해하기 위해서는 스프링 시큐리티와 JWT에 대한 이해 및 Access 토큰의 적용방법을 숙지하고 있어야 수월하여 위 포스팅 내용들을 먼저 읽어보고 오시기를 권장드립니다.


*Refresh Token

출처 : https://www.geeksforgeeks.org/jwt-authentication-with-refresh-tokens

  • 앞선 JWT 적용 포스팅에서 Access 토큰을 통한 로그인 처리를 알아보았다.
  • 하지만, 이 Access 토큰이 만약 탈취당한다면? 설정된 유효기간 동안 무한적으로 로그인이 가능하여 심각한 피해가 일어날 수 있다.
  • 이를 방지하기 위한 것이 바로 Refresh Token이다. 클라이언트로부터 요청이 왔을 때 Access Token의 유효기간이 만료되면 Refresh Token을 검증하여 유효하다면 Access Token을 재발급 해주는 방식이다.
  • 이를 통해 Access Token의 유효기간을 30분 등의 매우 짧은 단위로 적용할 수 있어, 탈취를 당하더라도 위험도가 상당히 줄어들 수 있다.

*Refresh 토큰을 적용한 JWT 로그인 인증 절차

- Access Token 유효할 시

  1. 사용자 로그인
  2. 사용자 확인
  3. 응답( + Access Token, Refresh Token) 이 때 Refresh Token은 DB에 별도 저장
  4. 클라이언트 -> 데이터 API 요청
  5. Access Token 검증
  6. 응답(+요청 데이터)

- Access Token 만료 시

  1. 클라이언트 -> 데이터 요청(+ Access Token)
  2. Access Token 만료 확인
  3. 응답 ( + Access Token이 만료 되었습니다)
  4. 클라이언트 -> Access Token 발급 요청(Access Token, Refresh Token)
  5. Refresh Token 검증 및 확인 후(토큰 유효, DB에 저장해 두었던 원본 Refresh 토큰과도 비교)
  6. 응답 ( + 새로운 Access Token + 새로운 Refresh Token)
    -> 이렇게 처리한 이유는 만약 해커가 Refresh Token을 탈취하여 정상적인 사용자가 Access 토큰 발급 받기 전에 해커가 먼저 Access 토큰을 생성하여 접근하는 경우 : Access Token이 충돌이 일어난다. 따라서 충돌된 두 토큰을 모두 폐기해야할 것이다.
    이를 애초에 방지하기 위해 국제 인터넷 표준화 기구인 IETF의 공식문서에서는 아예 Refresh Token도 Access Token과 같은 유효 기간을 가지도록 하여 사용자가 한 번 Refresh Token으로 Access Token을 발급받았으면 Refresh Token도 다시 발급받도록 하는 것을 권장하고 있다고 한다.
    -> 다시한번 공부해보니, 이처럼 처리하려면, Access Token도 DB에 Refresh Token과 함께 같은 행으로서 저장되야하는 것 같다.
    -> 본 포스팅 내용에서는 그렇게 진행하지 않았으나, 진행하던 프로젝트의 코드를 이와 같이 리펙토링해야할 것 같다.

*JWT - Refresh Token 적용하기

1. 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.auth0:java-jwt:4.0.0'
  • 시큐리티 적용과 jwt 적용을 위한 의존성을 추가해 줍니다.

2. application.properties

# jwt 비밀키 (아래는 예시값임)
jwt.secret=secretKey
  • 검증 시 사용될 비밀키는 최대한 노출되지 않도록 환경설정 파일에서 보관해주도록 합니다.

3. TokenProvider

@Component
public class TokenProvider {
    private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
    private final RefreshTokenRepository refreshTokenRepository;
    private MemberRepository memberRepository;

    public TokenProvider(MemberRepository memberRepository, RefreshTokenRepository refreshTokenRepository) {
        this.memberRepository = memberRepository;
        this.refreshTokenRepository = refreshTokenRepository;
    }

    @Value("{jwt.secret}")
    private String accessKey;

    private String refreshKey = "refresh " + accessKey;

    public String createAccessToken(Authentication authentication) {

        AuthDetails authDetails = (AuthDetails) authentication.getPrincipal();

        return JWT.create()
                .withSubject("MyPet JWT Access Token")
//                .withExpiresAt(new Date(System.currentTimeMillis() + (60 * 1000 * 60)))
                .withExpiresAt(new Date(System.currentTimeMillis() + (60 * 1000)))
                .withClaim("id", authDetails.getMember().getMemberId())
                .withClaim("email", authDetails.getEmail())
                .sign(Algorithm.HMAC512(accessKey));
    }

    private String createRefreshToken(Authentication authentication) {
        AuthDetails authDetails = (AuthDetails) authentication.getPrincipal();

        return JWT.create()
                .withSubject("MyPet JWT Refresh Token")
                .withExpiresAt(new Date(System.currentTimeMillis() + (60 * 1000 * 60 * 24 * 14)))
                .withClaim("email", authDetails.getEmail())
                .sign(Algorithm.HMAC512(refreshKey));
    }
    @Transactional
    public String renewalRefreshToken(Authentication authentication) {
        String newRefreshToken = createRefreshToken(authentication);
        // 기존 토큰이 있다면 바꿔주고, 없다면 토큰을 만들어준다.
        refreshTokenRepository.findByEmail(authentication.getName())
                .ifPresentOrElse(
                        r -> {r.changeToken(newRefreshToken);
                            logger.info("기존 리프레시 토큰 변환");},
                        () -> {
                            RefreshToken toSaveToken = RefreshToken.createToken(authentication.getName(), newRefreshToken);
                            logger.info("새로운 리프레시 토큰 저장 | member's email : {}, token : {}", toSaveToken.getEmail(), toSaveToken.getToken() );
                            refreshTokenRepository.save(toSaveToken);
                        }
                );
        return newRefreshToken;
    }

    @Transactional
    public String updateRefreshToken(String refreshToken) throws RuntimeException {
        // refresh Token을 DB에 저장된 토큰과 비교
        Authentication authentication = getAuthentication(refreshToken);
        RefreshToken findRefreshToken = refreshTokenRepository.findByEmail(authentication.getName())
                .orElseThrow(() -> new UsernameNotFoundException("email : " + authentication.getName() + " was not found"));

        // 토큰이 일치한다면
        if(findRefreshToken.getToken().equals(refreshToken)) {
            // 새로운 토큰 생성
            String newRefreshToken = createRefreshToken(authentication);
            findRefreshToken.changeToken(newRefreshToken);
            return newRefreshToken;
        } else {
            logger.info("refresh Token이 일치하지 않습니다.");
            return null;
        }
    }

    public Authentication getAuthentication(String token) {
    
        String email = JWT.decode(token).getClaim("email").asString();

        if(email != null) {
            Member memberEntity = memberRepository.findByEmail(email).orElseThrow(
                    () -> new UsernameNotFoundException(email + "데이터베이스에서 찾을 수 없습니다.")
            );

            AuthDetails authDetails = new AuthDetails(memberEntity);

            return new UsernamePasswordAuthenticationToken(authDetails, null, authDetails.getAuthorities());
        } else {
            return null;
        }
    }

    public JwtCode validateToken(String token, String tokenType) {
        String key = tokenType.equals("access") ? accessKey : refreshKey;

        try {
            JWT.require(Algorithm.HMAC512(key)).build().verify(token);
            return JwtCode.ACCESS;
        } catch (TokenExpiredException e) {
            logger.info("만료된 토큰입니다.");
            return JwtCode.EXPIRED;
        } catch (JWTVerificationException e) {
            logger.info("토큰 검증에 실패하였습니다.");
            return JwtCode.DENIED;
        }
    }

    public static enum JwtCode {
        DENIED,
        ACCESS,
        EXPIRED;
    }
}
  • @Value("{jwt.secret}") : 우선 환경 설정 파일에서 저장했던 비밀키값을 불러와 저장한다.
     특정 문자열을 추가하여 리프레시 토큰과 액세스 토큰의 키값을 구분하였다.
  • createAccessToken() : Authentication 객체가 인자로 오면, 그 객체에 저장된 유저 정보를 기반으로 액세스 토큰을 생성하여 토큰 값을 반환한다.
    createRefreshToken() 메서드도 마찬가지다.
  • renewalRefreshToken() : 인자로 들어온 Authentication 객체를 통해 새 리프레시 토큰을 생성하고, DB에 기존 리프레시 토큰이 있다면 바꿔주고 없다면 추가해준다. 로그인 시 이 메서드가 실행된다.
  • updateRefreshToken() : renuwalRefreshToken()과 유사해보이지만 다른 상황에 사용하는 메서드이다. Access 토큰이 만료되었을 때 사용하는 메서드이다. 인자로 들어온 Refresh 토큰값을 DB에 저장된 값과 비교하여, 일치할 경우에만 새 리프레시 토큰을 생성하여 기존 DB 값을 수정한 뒤 반환한다.
  • getAuthentication() : 인자로 들어온 token 값에 들어있는 유저정보를 기반으로 인증 객체인 Authentication 객체를 반환한다.
  • validateToken() : 인자로 들어온 토큰을 검증한다. 인자로 들어온 tokenType을 통해 액세스 / 리프레시를 구분한다. (같은 비밀키로 암호화하여 토큰을 생성하기에 이렇게 구분하여 처리하지 않으면 리프레시 토큰으로 접근해도 액세스 토큰 검증에 통과해버린다)
  • JwtCode : 토큰 검증의 성공/실패/유효기간 만료를 구분하기 위해 enum 타입으로 생성해두었다.

4. JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    private final TokenProvider tokenProvider;

    private final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        logger.info("로그인 시도");

        try {
        // 요청 데이터로부터 Member 객체를 받아온다.
            ObjectMapper om = new ObjectMapper();
            Member member = om.readValue(request.getInputStream(), Member.class);
        // Member 객체에 저장되어 있는 email, pasword 값(로그인 요청시 넘어오는 값)을 통해 인증 토큰 객체를 생성한다.
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member.getEmail(), member.getPassword());
        // 인증 토큰 객체를 통해 유저 인증을 시도한다. (UserDetailsService 또는 상속받은 구현체의 loadUserByUsername()가 실행됨)
            Authentication authentication = authenticationManager.authenticate(authenticationToken);
        // 인증이 성공하면 인증 객체를 반환하고, 실패하면 AuthenticationException을 발생시킨다.
            return authentication;

        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain, Authentication authResult) throws IOException, ServletException {
        logger.info(("로그인 성공 유저 처리"));
		// 액세스 토큰과 리프레시 토큰을 생성하여 응답 헤더에 담아준다.
        String accessToken = tokenProvider.createAccessToken(authResult);
        String refreshToken = tokenProvider.renewalRefreshToken(authResult);
        response.addHeader("Authorization", "Bearer " + accessToken);
        response.addHeader("refresh", "Bearer " + refreshToken);
    }
}

 

  • UsernamePasswordAuthenticationFilter 인터페이스를 상속받아야 로그인 시도 처리, 로그인 성공 후처리를 위한 필터로서 동작을 할 수 있다.
  • attemptAuthentication() : 로그인 시도 시 실행되는 메서드로 인증 성공 시 인증 객체를 반환하고, 인증 실패 시 AuthenticationException을 발생시킨다.
  • 기타 자세한 사항은 모두 주석처리하였으니 참고바랍니다.

5. JwtAuthorizationFilter

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private MemberRepository memberRepository;

    private TokenProvider tokenProvider;

    private final Logger logger = LoggerFactory.getLogger(JwtAuthorizationFilter.class);

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, MemberRepository memberRepository, TokenProvider tokenProvider) {
        super(authenticationManager);
        this.memberRepository = memberRepository;
        this.tokenProvider = tokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        logger.info("인증이나 권한 필요한 주소가 요청 되었습니다.");
	// 헤더에서 액세스 토큰을 받아온다.
        String jwtToken = resolveToken(request ,"Authorization");
	// 토큰이 존재한다면
        if(jwtToken != null) {
        // 토큰을 검증하고, 그 검증 후 값을 저장해둔다.
            TokenProvider.JwtCode accValidResult = tokenProvider.validateToken(jwtToken, "access");
		// 액세스 토큰이 유효하다면
            if(accValidResult == TokenProvider.JwtCode.ACCESS) {
            // 인증 객체를 얻어 SecurityContextHolder에 저장 후, 필터를 넘긴다.
                Authentication authentication = tokenProvider.getAuthentication(jwtToken);
                if(authentication != null) {
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                    filterChain.doFilter(request, response);
                }
            // 액세스 토큰이 만료되었다면
            } else if (accValidResult == TokenProvider.JwtCode.EXPIRED) {
            // 리프레시 토큰을 헤더에서 불러온다.
                String refreshToken = resolveToken(request, "refresh");
                // refresh Token이 요청헤더에 있다면
                if(refreshToken != null) {
                    // refresh Token을 검증
                    TokenProvider.JwtCode refValidResult = tokenProvider.validateToken(refreshToken, "refresh");
                    // 검증이 통과하면
                    if(refValidResult != TokenProvider.JwtCode.DENIED) {
                        // refresh Token 재발급
                        String newRefreshToken = tokenProvider.updateRefreshToken(refreshToken);
                        if(newRefreshToken != null) {
                            response.addHeader("refresh", "Bearer " + newRefreshToken);
                            // Access Token 재발급
                            Authentication authentication = tokenProvider.getAuthentication(refreshToken);
                            response.addHeader("Authorization", "Bearer " + tokenProvider.createAccessToken(authentication));
                            SecurityContextHolder.getContext().setAuthentication(authentication);
                            logger.info("리프레시 토큰 & 액세스 토큰 최신화 완료");
                        }
                    }
                } else
                    // refresh Token이 요청헤더에 없다면
                    // 클라이언트에 Access Token이 만료되었음을 알리고, refresh Token을 요청헤더에 달라고 요청(예외 처리)
                    // 해당 처리는 아래에서 구현할 ErrorhandlerFilter 클래스에서 처리한다.
                    throw new TokenExpiredException("토큰 만료", Instant.now());
            }
        } else {
            logger.info("유효한 토큰을 찾지 못하였습니다, uri : {}", request.getRequestURI());
        }
        filterChain.doFilter(request, response);
    }
	// 토큰을 헤더에서 불러오는 메서드
    private String resolveToken(HttpServletRequest request, String header) {
        String jwtHeader = request.getHeader(header);

        if(jwtHeader != null && jwtHeader.startsWith("Bearer")) {
            return jwtHeader.replace("Bearer ", "");
        }
        return null;
    }
}
  • BasicAuthenticationFilter을 상속받아 doFilterInternal() 메서드를 오버라이딩하면, 해당 메서드 내에서 인가 처리가 필요한 주소가 요청되었을 때의 처리가 가능하다.
  • 1. 액세스 토큰이 유효하다면 그대로 인가를 통과시킨다.
    2. 만료되었고 리프레시 토큰이 헤더에 존재하지 않는다면 TokenExpiredException을 발생시킨다.
    3. 만료되었고, 리프레시 토큰이 헤더에 존재한다면 // 토큰을 검증 후 리프레시 토큰과 액세스 토큰을 함께 재발급한다.

6. ErrorhandlerFilter

@Component
public class ErrorhandlerFilter extends OncePerRequestFilter {


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (TokenExpiredException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("refresh token need");
        }
    }
}
  • OncePerRequestFilter을 상속받아 필터 전체 진행 중 단 한번만 작동되는 필터를 만든다. 이 필터를 인가처리할 JwtAuthorizationFilter 전에 위치시키면 인가처리 실패시 이 필터가 동작되게 되어 인가에 대한 에러 처리를 할 수 있다.
  • JwtAuthorizationFilter에서 발생시켰던 TokenExpiredException를 try / catch문으로 잡아서 해당 에러발생 시, 리프레시 토큰이 필요하다고 클라이언트에게 응답 메시지를 커스텀하여 보내주도록 처리한다.

7. JwtAccessDeniedHandler

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        // 필요한 권한이 없이 접근하려할 때, 403 에러 송출
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
 }

8. SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CorsFilter corsFilter;

    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    private final MemberRepository memberRepository;

    private final TokenProvider tokenProvider;

    private final ErrorhandlerFilter errorhandlerFilter;

    private final JwtAddLogoutHandler jwtAddLogoutHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.csrf().disable();
        httpSecurity.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 디폴트인 세션, 쿠키 생성 허용 -> 불허
                .and()
                .httpBasic().disable()
                // JWT 필터 적용
                .apply(new JwtLogin())
                .and()
                // 인가에 대한 에러처리를 위해 errorhandlerFilter를 추가
                .addFilterBefore(errorhandlerFilter, JwtAuthorizationFilter.class)
                .exceptionHandling()
                // 권한이 필요한 요청 실패에 대한 처리를 커스텀
                .accessDeniedHandler(jwtAccessDeniedHandler)
                .and()
                .authorizeRequests()
                .antMatchers("/api/v1/admin/**").access("hasRole('ROLE_ADMIN')")
                .antMatchers("/api/v1/user/**").access("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
                .anyRequest().permitAll()
                .and()
                .logout()
                .addLogoutHandler(jwtAddLogoutHandler)
                .logoutSuccessUrl("/api/v1/user/test");

        return httpSecurity.build();
    }

	// JWT 필터를 적용시키기 위한 매개 클래스
    public class JwtLogin extends AbstractHttpConfigurer<JwtLogin, HttpSecurity> {
        @Override
        public void configure(HttpSecurity httpSecurity) throws Exception {
            AuthenticationManager authenticationManager = httpSecurity.getSharedObject(AuthenticationManager.class);
            httpSecurity
                    .addFilter(corsFilter)
                    .addFilter(new JwtAuthenticationFilter(authenticationManager, tokenProvider))
                    .addFilter(new JwtAuthorizationFilter(authenticationManager, memberRepository, tokenProvider));
        }
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • 위 사항들을 모두 적용시킨 Security 설정 파일이다. 설정 파일에 대한 자세한 내용은 최상단의 타 포스팅 내용을 참고 바랍니다.

9. AuthDetails

@Data
public class AuthDetails implements UserDetails {
    private final Member member;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(() -> String.valueOf(member.getMemberRole()));
        return authorities;
    }

    public String getEmail() {return member.getEmail();}

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getEmail();
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return true;
    }
}

10. AuthDetailsService

@Service
@RequiredArgsConstructor
public class AuthDetailsService implements UserDetailsService {
    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member memberEntity = memberRepository.findByEmail(email).orElseThrow(
                () -> new UsernameNotFoundException(email + "데이터베이스에서 찾을 수 없습니다.")
        );
        return new AuthDetails(memberEntity);
    }
}

11. RefreshToken (엔티티)

@Entity
@NoArgsConstructor
@Getter
@AllArgsConstructor
@Builder
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    private Long refreshTokenId;

    private String email;
    private String token;

    public  static RefreshToken createToken(String email, String token) {
        return RefreshToken.builder().email(email).token(token).build();
    }

    public void changeToken(String token) {
        this.token = token;
    }
}

12. RefreshTokenRepository

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
    Optional<RefreshToken> findByEmail(String email);
    void deleteByEmail(String email);
}

*동작 테스트

1. 회원가입

2. 로그인

  • 액세스 토큰, 리프레시 토큰 모두 잘 들어오는 것 확인
  • DB에 리프레시 토큰도 잘 저장되어있다.

3. 인증 필요 url 접근

  • 잘 통과된다.

4. 유효성 만료시

  • 커스텀한대로 클라이언트에 refresh 토큰이 필요하다는 메시지를 전달하고 있다.

5. Refresh Token 재발급

  • 접근 불가능하던 url에 접근이 가능해졌으며 토큰이 최신화되어 DB에 저장됨을 확인.
Comments