본문 바로가기
스프링

[Spring Security] + [JWT] + [RefreshToken] 스프링 시큐리티 JWT 로그인 적용기

by DarrenH 2022. 8. 18.
반응형

오늘은 저번 포스팅에서 했던 Security + JWT로 로그인을 구현했습니다. 이번에는 RefreshToken을 이용하려고 하는데요 이 RefreshToken을 이용하는 이유를 설명하겠습니다.

 

먼저 저희가 로그인 하면 AccessToken을 발급받게 됩니다. 이 AccessToken은 유효시간이 짧습니다. 저번 코드에서는 30분이었는데 이번 코드에서는 실습을 위해 2분으로 설정했습니다. 이렇게 AccessToken의 유효시간은 짧아서 매 30분마다 로그인을 다시 해야 하는데요 이런 불편함을 해결하고자 RefreshToken을 사용하게 됩니다.

 

기본적인 로직을 설명드리겠습니다.

1. 사용자가 로그인시 AccessToken, RefreshToken을 발급합니다. 이 두 Token은 Front 측에서 LocalStorege에 보관합니다. 

 

2. 이때 RefreshToken은 Server 측에서 관리해주기 위해 DB에 따로 저장합니다. 저희 소스에서는 현재 접속한 기기의 정보 또한 저장합니다. 이때 접속한 기기의 정보는 Header에 User-Agent의 값으로 설정했습니다. 

 

3. 사용자가 AccessToken으로 인증이 필요한 페이지를 요청하면 유효한 AccessToken일 경우 정상 접속이됩니다.

 

4. 하지만 AccessToken이 유효한 토큰이 아닐 경우 RefreshToken을 이용하여 AccessToken을 재발급합니다.

 

5. 프런트는 AccessToken을 새로 LocalStorege에 다시 저장합니다. AccessToken이 갱신되었으므로 다시 API요청을 정상적으로 수행합니다. 

 

위 로직을 Spring Security를 이용하여 코드로 설명드리겠습니다.

 

먼저 프로젝트 구조는 다음과 같습니다.

보시면 Board와 관련된 클래스는 현 포스팅과 관련 없으므로 무시해주세요.

 

<전체 소스 코드>

https://github.com/Darren4641/Spring-Security-JWT

 

GitHub - Darren4641/Spring-Security-JWT: JWT login with Spring Security + RefreshToken

JWT login with Spring Security + RefreshToken. Contribute to Darren4641/Spring-Security-JWT development by creating an account on GitHub.

github.com

 

먼저 사용자 정보를 보여드리겠습니다. 

package com.Model;

import com.Role.Role;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@Builder
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class Users implements UserDetails {

    @Id
    private String id;

    @Column(length = 255)
    private String password;

    @Column(length = 255, nullable = false)
    private String email;

    private String gender;

    @Column(length = 13)
    private String phone;

    @Column(length = 8)
    @Enumerated(EnumType.STRING)
    private Role role;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 저번 포스팅에서도 설명했듯이 Spring Security를 이용하기 위해 UserDetails를 implements 하여 구현하였습니다. 저번 포스팅을 참고해주세요.

 

https://darrenh.tistory.com/11

 

[Spring Security] + [JWT] 스프링 시큐리티 JWT 로그인[실습]

저번 포스팅에 이어서 이번에는 직접 코드를 작성하도록하겠습니다. 버전 : Spring Boot 2.7.2 SQL : JPA IDEA : IntelliJ 디렉토리 구조 저희가 Spring Security와 JWT를 사용해야하므로 해당 Gradle을 추가해줍..

darrenh.tistory.com

 

그리고 RefreshToken을 DB에 저장하기 위해 따로 객체로 만들었습니다.

 

package com.Jwt;


import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Builder
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long refreshTokenId;

    private String refreshToken;

    private String keyId;

    private String userAgent;
}

그리고 사용자에게 값을 전달해줄 Token을 따로 클래스로 추출하였습니다.

package com.Jwt;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Token {
    private String grantType;
    private String accessToken;
    private String refreshToken;
    private String key;
}

그리고 저는 사용자의 권한의 경우 권한에 따른 게시판 작성 및 요청을 위해 따로 enum으로 빼주었습니다. 

package com.Role;

import com.fasterxml.jackson.annotation.JsonCreator;
import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum Role {
    ADMIN("ROLE_ADMIN"),
    USER("ROLE_USER");

    private String value;

    @JsonCreator
    public static Role from(String s) {
        return Role.valueOf(s.toUpperCase());
    }
}

그럼 기본적인 준비는 끝이 났습니다. 추가로 저는 JPA를 이용했기 때문에 Repository를 생성해줍니다.

package com.Repository;

import com.Model.Users;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UsersRepository extends JpaRepository<Users, String> {
    Optional<Users> findById(String id);
    Optional<Users> findByEmail(String email);
    Page<Users> findAll(Pageable pageable);
}

현재 findByEmail은 사용하고 있지 않습니다.

 

먼저 저희가 SpringSecurity 설정을 해주어야 하기 때문에 SecurityConfig.class를 작성해줍니다.

package com.Security;

import com.ErrorHandler.AutheniticationEntryPointHandler;
import com.ErrorHandler.WebAccessDeniedHandler;
import com.Jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final AutheniticationEntryPointHandler autheniticationEntryPointHandler;
    private final WebAccessDeniedHandler webAccessDeniedHandler;

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        //http.httpBasic().disable(); // 일반적인 루트가 아닌 다른 방식으로 요청시 거절, header에 id, pw가 아닌 token(jwt)을 달고 간다. 그래서 basic이 아닌 bearer를 사용한다.
        http.httpBasic().disable()
                .authorizeRequests()// 요청에 대한 사용권한 체크
                .antMatchers("/test").authenticated()
                .antMatchers("/write").authenticated()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasRole("USER")
                .antMatchers("/**").permitAll()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(autheniticationEntryPointHandler)
                .accessDeniedHandler(webAccessDeniedHandler)
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class); // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다

        // + 토큰에 저장된 유저정보를 활용하여야 하기 때문에 CustomUserDetailService 클래스를 생성합니다.
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        return http.build();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
                .antMatchers("/h2-console/**")
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }
}

보시면 저번 포스팅과 다른 점이 추가되었습니다. 보시면 에러를 핸들링해주었는데요.

exceptionHandling()  - 예외처리 기능 작동

authenticationEntryPoint(EntryPonit) - EntryPoint를 이용하여 인증 실패 시 처리

accessDeniedHandler(AccessDeniedHandler) - 인가 실패시 처리

 

먼저 Filter소스입니다.

package com.Security;

import com.Jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;


    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        //헤더에서 JWT를 받아온다.
        String AccessToken = jwtTokenProvider.getAccessToken((HttpServletRequest) request);

        //토큰 유효성 검사
        if(AccessToken != null && jwtTokenProvider.validateToken(request, AccessToken)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(AccessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }
}

 

authenticationEntryPointHandler와 webAccessDeniedHandler는 저희가 따로 커스텀해주었습니다.

package com.ErrorHandler;

import lombok.RequiredArgsConstructor;
import org.json.simple.JSONObject;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@RequiredArgsConstructor
public class AutheniticationEntryPointHandler implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String exception = (String) request.getAttribute("exception");
        ErrorCode errorCode;

        if(exception == null) {
            errorCode = ErrorCode.UNAUTHORIZEDException;
            setResponse(response, errorCode);
            return;
        }

        //토큰이 만료된 경우
        if(exception.equals("ExpiredJwtException")) {
            errorCode = ErrorCode.ExpiredJwtException;
            setResponse(response, errorCode);
            return;
        }
        
        //아이디 비밀번호가 다를 경우
        if(exception.equals("UsernameOrPasswordNotFoundException")) {
            errorCode = ErrorCode.UsernameOrPasswordNotFoundException;
            setResponse(response, errorCode);
        }

    }

    private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException{
        JSONObject json = new JSONObject();
        response.setContentType("application/json;charset=UTF-8");
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        json.put("code", errorCode.getCode());
        json.put("message", errorCode.getMessage());
        response.getWriter().print(json);

    }
}
;

SecurityConfig에서 사용하기 위해 Bean에 등록하기 위해 @Component를 달았습니다.

AuthenticationEntryPoint를 implements 받아 구현해야 하는데요 commence() 메서드를 구현해야 합니다. Security에서 인증 실패 시 commence 메서드를 실행하게 됩니다. 

 

ErrorCode는 Error를 enum Type으로 모아놓은 객체입니다. request에서 Exception의 값을 가져와 따로 저장한 다음에 에러 처리를 해주었습니다. 

 

그럼 ErrorCode를 살펴보겠습니다. 

package com.ErrorHandler;

import lombok.Getter;
import org.springframework.http.HttpStatus;

public enum ErrorCode {
    UsernameOrPasswordNotFoundException (400, "아이디 또는 비밀번호가 일치하지 않습니다.", HttpStatus.BAD_REQUEST),
    ForbiddenException(403, "해당 요청에 대한 권한이 없습니다.", HttpStatus.FORBIDDEN),
    UNAUTHORIZEDException (401, "로그인 후 이용가능합니다.", HttpStatus.UNAUTHORIZED),
    ExpiredJwtException(444, "기존 토큰이 만료되었습니다. 해당 토큰을 가지고 /refresh 링크로 이동해주세요.", HttpStatus.UNAUTHORIZED),
    ReLogin(445, "모든 토큰이 만료되었습니다. 다시 로그인해주세요.", HttpStatus.UNAUTHORIZED),
    ;
    @Getter
    private int code;
    @Getter
    private String message;
    @Getter
    private HttpStatus status;

    ErrorCode(int code, String message, HttpStatus status) {
        this.code = code;
        this.message = message;
        this.status = status;
    }

    public String toString() {
        return "{" +
                    "\"code\" : " + "\""+code+"\"" +
                    "\"status\" : " + "\""+status+"\"" +
                    "\"message\" : " + "\""+message+"\"" +
                "}";
    }
}

이렇게 인증이 실패 시 예외처리를 해주었고 이제 인가 처리 실패시 예외처리를 하도록 하겠습니다.

 

package com.ErrorHandler;

import org.json.simple.JSONObject;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class WebAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ErrorCode errorCode = ErrorCode.ForbiddenException;

        response.setContentType("application/json;charset=UTF-8");
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        JSONObject json = new JSONObject();
        json.put("code", errorCode.getCode());
        json.put("message", errorCode.getMessage());
        response.getWriter().print(json);
    }
}

인가 실패 시 ForbiddenException을 던지게 됩니다.

 

인증 예외처리 시 Exception을 남기기 위해 AuthenticationException을 이용하겠습니다. abstract 클래스이므로 저희가 따로 커스텀해주어야 합니다.

package com.ErrorHandler;

import org.springframework.security.core.AuthenticationException;

public class AuthenticationCustomException extends AuthenticationException {

    public AuthenticationCustomException(String msg, Throwable cause) {
        super(msg, cause);
    }

    public AuthenticationCustomException(String msg) {
        super(msg);
    }

    public AuthenticationCustomException(ErrorCode errorCode) {
        super(errorCode.toString());
    }
}

에러를 던질 때 메시지 대신 ErrorCode를 이용하여 에러를 출력하도록 했습니다.

 

저희가 Error나 리턴값을 던질 때 JSON형태로 값을 출력하기 위해 ApiResponse를 작성하겠습니다.

package com.ErrorHandler;

import lombok.Getter;
import lombok.Setter;
import org.springframework.http.HttpStatus;

import java.util.HashMap;
import java.util.Map;

@Setter
@Getter
public class ApiResponse {
    private int code = HttpStatus.OK.value();
    private Object result;

    public ApiResponse() { }

    public void setResult(Object result) {
        this.result = result;
    }

    public static class ResponseMap extends ApiResponse {

        private Map responseData = new HashMap();

        public ResponseMap() {
            setResult(responseData);
        }

        public void setResponseData(String key, Object value) {
            this.responseData.put(key, value);
        }

    }
}

 

이제 모든 준비가 끝났으므로 Controller부터 설명드리겠습니다. 

package com.Controller;

import com.ErrorHandler.ApiResponse;
import com.Model.Users;
import com.Repository.UsersRepository;
import com.Role.Role;
import com.Service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.Map;

@RequiredArgsConstructor
@RestController
public class UsersController {

    private final PasswordEncoder passwordEncoder;

    private final UsersRepository usersRepository;

    private final JwtService jwtService;

    @PostMapping("/test")
    public String test(){
        return "<h1>test 통과</h1>";
    }

    @PostMapping("/join")
    public String signUp(@RequestBody Map<String, String> user) {
        Role role = Role.from(user.get("role"));
        return usersRepository.save(Users.builder()
                .id(user.get("id"))
                .password(passwordEncoder.encode(user.get("password")))
                .email(user.get("email"))
                .gender(user.get("gender"))
                .phone(user.get("phone"))
                .role(role)
                .roles(Collections.singletonList(role.getValue()))
                .build()).toString();
    }

    @PostMapping("/login")
    public ApiResponse login(HttpServletRequest request,  @RequestBody Map<String, String> user, @RequestHeader("User-Agent") String userAgent) {
        return jwtService.login(request, user, userAgent);
    }

}

 

 

저번 포스팅처럼 /join의 경우 그대로 회원가입을 하게 됩니다.

 

그리고 "/login"으로 요청을 받게 되면 jwtService.login() 메서드를 타게 됩니다. 

package com.Service;

import com.ErrorHandler.ApiResponse;
import com.ErrorHandler.AuthenticationCustomException;
import com.ErrorHandler.ErrorCode;
import com.Jwt.JwtTokenProvider;
import com.Jwt.RefreshToken;
import com.Jwt.Token;
import com.Model.Users;
import com.Repository.RefreshTokenRepository;
import com.Repository.UsersRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class JwtService {

    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final AuthenticationManager authenticationManager;
    private final UsersRepository usersRepository;
    
    @Transactional
    public ApiResponse login(HttpServletRequest request, Map<String, String> user, String userAgent) {
        ApiResponse.ResponseMap result = new ApiResponse.ResponseMap();
        try {
            authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(user.get("id"), user.get("password"))
            );

            Users userinfo = usersRepository.findById(user.get("id")).get();
            Token token = jwtTokenProvider.createToken(user.get("id"), userinfo.getRoles());
            
            //RefreshToken을 DB에 저장
            RefreshToken refreshToken = RefreshToken.builder()
                    .keyId(token.getKey())
                    .refreshToken(token.getRefreshToken()).
                    userAgent(userAgent).build();
            String loginUserId = refreshToken.getKeyId();

            //다음번 로그인시 UserAgent값이 다를 경우 다른 기기에서 로그인한경우인데 여기 if문에서 처리해주면 된다
            if(refreshTokenRepository.existsByKeyIdAndUserAgent(loginUserId, userAgent)) {
                refreshTokenRepository.deleteByKeyIdAndUserAgent(loginUserId, userAgent);
            }
            refreshTokenRepository.save(refreshToken);

            result.setResponseData("accessToken", token.getAccessToken());
            result.setResponseData("refreshToken", token.getRefreshToken());
            result.setResponseData("key", token.getKey());
        }catch (Exception e) {
            e.printStackTrace();
            request.setAttribute("exception", "UsernameOrPasswordNotFoundException");
            throw new AuthenticationCustomException(ErrorCode.UsernameOrPasswordNotFoundException);
        }
        return result;
    }

    public ApiResponse newAccessToken(RefreshToken refreshToken) {
        ApiResponse.ResponseMap result = new ApiResponse.ResponseMap();
        if(refreshToken.getRefreshToken() != null) {
            String newToken = jwtTokenProvider.validateRefreshToken(refreshToken);
            result.setResponseData("accessToken", newToken);
        }else {
            result.setResponseData("code", ErrorCode.ReLogin.getCode());
            result.setResponseData("message", ErrorCode.ReLogin.getMessage());
            result.setResponseData("HttpStatus", ErrorCode.ReLogin.getStatus());
        }
        return result;
    }

}

먼저 사용자의 id와 password가 맞는지 authenticationManager.authenticate를 이용하여 검사해줍니다. 이때 다를 경우 catch구문으로 이동하는데요 에러를 핸들링해주었습니다.

 

사용자의 정보가 맞다면 사용자의 정보를 저장합니다. 그 정보를 가지고 AccessToken을 만듭니다.

 

그리고 RefreshToken도 만들어준 뒤 DB에 저장해줍니다. 이때 다른 기기에서 로그인할 경우 UserAgent값이 다른데 이경우 기존의 데이터를 삭제 후 만다시 DB에 저장해줍니다. 그리고 리턴 값으로는 accesstoken과 refreshtoken 그리고 key값을 전달해줍니다.

 

JwtTokenProvider

package com.Jwt;

import com.ErrorHandler.AuthenticationCustomException;
import com.ErrorHandler.ErrorCode;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import java.util.Base64;
import java.util.Date;
import java.util.List;

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
    private String accessSecretKey = "Darren";
    private String refreshSecretKey = "DarrenH";

    //유효시간 2분
    private long accessTokenValidTime = 2 * 60 * 1000L;
    //유효시간 3분
    private long refreshTokenValidTime = 3 * 60 * 1000L;
    //유효시간 31일
//    private long refreshTokenValidTime = 30 * 24 * 60 * 60 * 1000L;

    private final UserDetailsService userDetailsService;

    //secretKey를 Base64로 인코딩
    @PostConstruct
    protected void init() {
        accessSecretKey = Base64.getEncoder().encodeToString(accessSecretKey.getBytes());
        refreshSecretKey = Base64.getEncoder().encodeToString(refreshSecretKey.getBytes());
    }

    //jwt 토큰 생성
    public Token createToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk);// JWT PayLoad에 저장되는 정보단위, PK값
        claims.put("roles", roles);
        Date currentTime = new Date();

        String accessToken = getToken(claims, currentTime, accessTokenValidTime, accessSecretKey);
        String refreshToken = getToken(claims, currentTime, refreshTokenValidTime, refreshSecretKey);

        return Token.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .key(userPk).build();
    }

    private String getToken(Claims claims, Date currentTime, long tokenValidTime, String secretKey) {
        return Jwts.builder()
                .setClaims(claims) //정보 저장
                .setIssuedAt(currentTime)  //토큰 발행시간 정보
                .setExpiration(new Date(currentTime.getTime() + tokenValidTime)) //Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey)  //암호화 알고리즘
                .compact();
    }

    //jwt 토큰 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        if(userDetails == null)
            return null;
        else
            return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(accessSecretKey).parseClaimsJws(token).getBody().getSubject();
    }

    //Header에서 token값을 가지고온다.
    public String getAccessToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }

    //토큰의 유효성 검사
    public boolean validateToken(ServletRequest request, String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(accessSecretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (MalformedJwtException e) {
            request.setAttribute("exception", "MalformedJwtException");
        } catch (ExpiredJwtException e) {
            request.setAttribute("exception", "ExpiredJwtException");
        } catch (UnsupportedJwtException e) {
            request.setAttribute("exception", "UnsupportedJwtException");
        } catch (IllegalArgumentException e) {
            request.setAttribute("exception", "IllegalArgumentException");
        }
        return false;
    }

    // RefreshToken 유효성 검증 메소드
    public String validateRefreshToken(RefreshToken refreshTokenObj) {
        String refreshToken = refreshTokenObj.getRefreshToken();

        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(refreshSecretKey).parseClaimsJws(refreshToken);
            //AccessToken이 만료되지않았을떄만
            /*if(!claims.getBody().getExpiration().before(new Date())) {
                return recreationAccessToken(claims.getBody().get("sub").toString(), claims.getBody().get("roles"));
            }*/
            return recreationAccessToken(claims.getBody().get("sub").toString(), claims.getBody().get("roles"));
        }catch (Exception e) {
            e.printStackTrace();
            throw new AuthenticationCustomException(ErrorCode.ExpiredJwtException);
        }
        //토큰 만료시 login페이지 reDirect
    }

    //AccessToken 새로 발급
    private String recreationAccessToken(String email, Object roles) {
        Claims claims = Jwts.claims().setSubject(email);
        claims.put("roles", roles);
        Date currentTime = new Date();
        return getToken(claims, currentTime, accessTokenValidTime, accessSecretKey);
    }

}

저번 포스팅과 다른 점을 설명해드리겠습니다. 

 

아까 SecurityConfig에서 AuthenticationEntryPoint에서 인증 실패 시 처리를 해준다 했는데요, 저희가 커스텀한 Filter를 보시면 validateToken을 실행하는데 이때 에러를 핸들링해서 커스텀한 EntryPoint를 적용시켜주게 됩니다. 

validateRefreshToken() - 이 메서드는 RefreshToken의 유효성을 검사해서 유효하면 AccessToken을 재 발급해주는 메서드입니다. 

 

추가로 필요한 소스입니다. 

RefreshTokenRepository.interface

package com.Repository;

import com.Jwt.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByRefreshToken(String refreshToken);
    boolean existsByKeyIdAndUserAgent(String userEmail, String userAgent);
    void deleteByKeyIdAndUserAgent(String userEmail, String userAgent);
}

UserService.class 혹은 UserDetailService.class

package com.Service;

import com.ErrorHandler.AuthenticationCustomException;
import com.ErrorHandler.ErrorCode;
import com.Model.Users;
import com.Repository.UsersRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {

    private final UsersRepository usersRepository;

    @Override
    public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
        Optional<Users> user = usersRepository.findById(id);

        if(user.isPresent()) {
            return user.get();
        }
        throw new AuthenticationCustomException(ErrorCode.UsernameOrPasswordNotFoundException);
    }

}

 

RefreshController.class

package com.Controller;

import com.ErrorHandler.ApiResponse;
import com.Jwt.RefreshToken;
import com.Service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class RefreshController {

    private final JwtService jwtService;

    @PostMapping("/refresh")
    public ApiResponse validateRefreshToken(@RequestBody RefreshToken bodyJson) {
        return jwtService.newAccessToken(bodyJson);

    }
}

"/refresh"로 요청 시 새로운 AccessToken을 발급해줍니다.

 

"/join" 요청시 Postman
"login" 성공시 return 값
"/test"로 요청시 토큰 값 확인 후 유효한 토큰이면 정상 페이지
AccessToken이 만료시 페이지
"/refresh"로 AccessToken 재발급

해당 AccessToken을 가지고 다시 "/Test"로 요청하면 정상적으로 로그인이 되는 것을 볼 수 있다. 

 

기존 토큰으로 로그인 시도시 해당 메시지 출력
RefreshToken이 만료시 해당 에러 처리

 

참조 자료

https://kdg-is.tistory.com/228

 

Spring boot - security-jwt 연동과 흐름(feat.AccessToken, RefreshToken)

스프링 시큐리티는 뭘까? Spring boot - Spring Security에 대하여 Spring Security..?? 스프링 시큐리티가 뭘까? 프로젝트에 스프링 시큐리티를 적용하면서 적용은 됬는데 어떠한 흐름인지, 어떻게 보안을 적

kdg-is.tistory.com

https://velog.io/@jkijki12/Jwt-Refresh-Token-%EC%A0%81%EC%9A%A9%EA%B8%B0

 

Jwt Refresh Token 적용기

오늘은 이전에 포스팅한 jwt 적용기의 2편이다.문제인식해결방법구현Access Token을 적용하고 아주 큰? 문제를 발견했다.보안 상으로 Access Token은 매우 짧은 만료기간을 가지고 있다. 그래서 사용자

velog.io

 

반응형