[Spring] JWT 로그인 구현하기 2

이멀젼씨

·

2021. 10. 25. 15:05

목적

기존에 구현한 JWT로그인을 보완하고자 함

목차

  • 기존의 흐름
  • 새로 구현한 JWT로그인의 흐름
  • 코드 설명

기존의 흐름

https://emgc.tistory.com/133
기존에 작성한 JWT로그인이며 흐름은 아래와 같다.

  • 필터

    로그인 시 쿠키에 있는 토큰을 확인하고 유효한 토큰이라면 인증객체를 생성하고 요청을 넘긴다

  • 컨트롤러

    인증객체의 확인 없이 클라이언트가 입력한 아이디와 비밀번호를 통해 사용자를 인증하고 인증된 사용자의 정보로 토큰을 발급한다.

  • 하지만 아래와 같은 문제점이 있어서 새로운 JWT로그인이 필요했다.

  • 토큰을 갖고 있어도 매번 아이디 비밀번호를 입력해야 로그인 가능
  • 토큰 자체의 만료시간이 짧기 때문에 만료될때마다 아이디 비밀번호를 입력
  • 새로 구현한 JWT로그인의 흐름

    토큰을 access token, refresh token 두 가지로 구분하였다.

    이유는 아래와 같다.

    1. 한 가지 토큰만 사용했을 경우 토큰이 탈취된다면 해당 토큰으로 사용자의 정보에 접근이 가능하다. 이를 막고자 토큰의 유효기간은 짧게 만들고, 또 다른 토큰을 만들어 이를 보완하고자 하였다.
    2. 만료 기간이 짧아지면 자주 로그인을 해주어야 하며 이는 사용자에게 굉장한 불편을 가져다준다.

    각 토큰은 개발자 입맛에 따라 어떠한 정보를 넣을지, 유효기간은 얼마나 설정할 지 등 커스텀이 가능하다.

    필자는 access token과 refresh token 모두 동일한 정보를 갖고 있도록 하고, refresh token의 만료기간만 아주 길게 설정하였다.

    전체적인 흐름

  • access token 검증

    access token을 먼저 검증한다.
    access token이 유효한 토큰이라면 인증객체를 생성한다.

  • refresh token 검증

    access token이 유효하지 않은 경우 refresh token을 검증한다.
    refresh token이 유효한 토큰이라면 인증객체를 생성한다.

  • 인증객체 확인

    컨트롤러에서 인증객체가 생성되어있는지 확인한다.
    인증객체가 생성되어 있다면 사용자 인증이 되었으므로 인증객체로 토큰을 생성하여 발급한다.

  • 유저 아이디 비번 검증

    인증객체가 없다면 사용자의 아이디와 비밀번호로 검증한다.
    아이디 비밀번호가 일치하는 경우 토큰을 발급한다.

  • 1,2번은 필터에서 3,4번은 컨트롤러에서 진행된다.

    인증객체 저장소를 두고 각각 독립적으로 필터와 컨트롤러가 객체를 생성 및 접근하도록 하여 서로 의존하지 않도록 만들었다.

    코드 설명

    의존성 추가

    • JWT의 관리를 도와주는 의존성을 추가한다.
    implementation 'io.jsonwebtoken:jjwt:0.9.1'

    만료된 refresh token 관리

    • refresh token은 만료기간이 길기 때문에 기존의 토큰이 사용될 우려가 있다. 따라서 로그인 시 새롭게 refresh token을 발급되기 때문에 기존의 토큰을 사용하지 못하도록 조치를 취해야 한다.

    • 만료된 refresh token을 관리하는 ExpiredRefreshToken 엔티티와 ExpiredRefreshTokenRepository, ExpiredRefreshTokenService를 생성한다

    @Entity
    @Getter
    @Builder
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @AllArgsConstructor(access = AccessLevel.PROTECTED)
    @EntityListeners(AuditingEntityListener.class)
    public class ExpiredRefreshToken {
      @Id
      @GeneratedValue
      private Long id;
    
      private String token;
    }

    ExpiredRefreshTokenRepository

    public interface ExpiredRefreshTokenRepository extends JpaRepository<ExpiredRefreshToken, Long> {  
       boolean existsByToken(String token);  
    }

    ExpiredRefreshTokenService

    @Service
    @RequiredArgsConstructor
    public class ExpiredRefreshTokenService {
    
        private final ExpiredRefreshTokenRepository expiredRefreshTokenRepository;
    
        public boolean isExpiredToken(String token) {
            return expiredRefreshTokenRepository.existsByToken(token);
        }
    
        public ExpiredRefreshToken addExpiredToken(String token) {
            ExpiredRefreshToken saveToken = ExpiredRefreshToken.builder()
                .token(token)
                .build();
            return expiredRefreshTokenRepository.save(saveToken);
        }
    }

    JWT를 관리하는 JwtUtil

    • jwt를 관리하는 JwtUtil을 생성해준다.

    • refresh token과 access token을 생성 방식은 동일하게 설정해주었다.

      필요에 따라 다른 로직으로 생성해줄 수 있다.

    • refresh token의 검증에는 ExpiredRefreshtokenService에서 만료된 토큰인지 확인하고 만료되지 않은 경우에만 validateToken메소드에 토큰 검증을 위임한다.

    @Component
    @RequiredArgsConstructor
    public class JwtUtil {
    
      private final ExpiredRefreshTokenService expiredRefreshTokenService;
      private final UserService userService;
    
      @Value("${jwt.secret}")
      private String secretKey;
    
      private final long ACCESS_TOKEN_VALID_TIME = 1000L * 60 * 60; //1시간
      private final long REFRESH_TOKEN_VALID_TIME = 1000L * 60 * 60 * 24 * 60; // 2달
    
      public String createAccessToken(String userPk, String role) {
          Claims claims = Jwts.claims().setSubject(userPk);
          claims.put("role", role);
          Date now = new Date();
    
          return Jwts.builder()
              .setClaims(claims)
              .setIssuedAt(now)
              .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME))
              .signWith(SignatureAlgorithm.HS256, secretKey)
              .compact();
      }
    
      public String createRefreshToken(String userPk, String role) {
          Claims claims = Jwts.claims();
          claims.put("role", role);
          Date now = new Date();
          Date expiration = new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME);
    
          return Jwts.builder()
              .setClaims(claims)
              .setIssuedAt(now)
              .setExpiration(expiration)
              .signWith(SignatureAlgorithm.HS256, secretKey)
              .compact();
      }
    
      public Authentication getAuthentication(String token) {
          String email = getUserPk(token);
          User user = userService.getUser(email);
          return new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities());
      }
    
      public String getUserPk(String token) {
          return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
      }
    
      public String resolveAccessToken(HttpServletRequest request) {
          String token = request.getHeader("access-token");
          return token;
      }
    
      public String resolveRefreshToken(HttpServletRequest request) {
          String token = null;
          Cookie cookie = WebUtils.getCookie(request, "refresh-token");
          if (cookie != null)
              token = cookie.getValue();
          return token;
      }
    
      public boolean validateToken(String jwtToken) {
          try {
              Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
              return !claims.getBody().getExpiration().before(new Date());
          } catch (Exception e) {
              return false;
          }
      }
    
      public boolean validateRefreshToken(String jwtToken) {
          if(expiredRefreshTokenService.isExpiredToken(jwtToken)) {
              return false;
          }
    
          return validateToken(jwtToken);
      }
    }

    JWT인증 필터

    • jwt인증 필터를 만들어주고, 생성자 파라미터로 jwtUtil을 주입받는다.

    • header에 access token을 먼저 확인한다. 유효한 경우라면 토큰으로부터 유저 정보를 불러와 인증객체를 만든다.

    • 유효하지 않다면 cookie에 있는 refresh token을 확인한다. 토큰이 유효한 경우 마찬가지로 인증객체를 만들어 저장한다.

    • 만약 access token, refresh token 둘 다 유효하지 않거나 없는 경우 인증객체를 생성하지 않고 요청을 다음 필터로 넘긴다.

    public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
        private final JwtUtil jwtUtil;
    
        public JwtAuthenticationFilter(JwtUtil jwtUtil) {
            this.jwtUtil = jwtUtil;
        }
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                        FilterChain filterChain) throws ServletException, IOException {
    
            String accessToken = jwtUtil.resolveAccessToken(request);
            boolean isAccessTokenValid = accessToken != null && jwtUtil.validateToken(accessToken);
    
            try {
                if (isAccessTokenValid) {
                    Authentication authentication = jwtUtil.getAuthentication(accessToken);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                } else {
                    String refreshToken = jwtUtil.resolveRefreshToken(request);
                    if (refreshToken != null && jwtUtil.validateRefreshToken((refreshToken))) {
                        Authentication authentication = jwtUtil.getAuthentication(refreshToken);
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            } catch (Exception e) {
    
            }
            filterChain.doFilter(request, response);
        }
    }

    Spring Security 설정

    • Spring Security에서 UsernamePasswordAuthenticationFilter 이전에 동작하도록 설정한다.
    • /login 경로에 대한 접근은 모두 허용한다.
    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        private final JwtUtil jwtUtil;
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                .httpBasic().disable()
                .csrf().disable()
                .formLogin().disable()
                .cors()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/login/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
        }
    
    }
    

    컨트롤러에서 인증

    • 컨트롤러에 로그인 핸들러 메소드를 만들어준다.
    • 인증객체가 생성되어있는지 확인한다. 생성 되어있다면 인증객체로부터 데이터를 꺼내어 새롭게 토큰을 만들어준다.
    • 인증객체가 생성되어있지 않다면 유저가 입력한 아이디와 비밀번호를 확인하게 된다.
      이를 통해 맞다면 토큰 발급 틀리다면 토큰을 발급하지 않는다.
    @RestController
    @RequiredArgsConstructor
    public class UserApi {
    
        private final UserService userService;
        private final PasswordEncoder passwordEncoder;
        private final ExpiredRefreshTokenService expiredRefreshTokenService;
    
        @PostMapping("/login")
        public ResponseEntity<UserRes> login(@RequestBody(required = false) @Valid UserLoginReq req,
            HttpServletRequest request,
            HttpServletResponse response) {
            User user = null;
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    
            if (authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)) {
                user = (User)authentication.getPrincipal();
            } else {
                if (req != null) {
                    user = userService.getUser(req.getEmail());
                    if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) {
                        //아이디 비밀번호 미일치 시 처리할 로직
                    }
                }
            }
    
            if (user == null) {
                //유저객체를 제대로 불러오지 못할때 처리할 로직
            }
    
            String expiredToken = jwtUtil.resolveRefreshToken(request);
            if (expiredToken != null && !expiredToken.isBlank()) {
                expiredRefreshTokenService.addExpiredToken(expiredToken);
            }
    
            String accessToken = jwtUtil.createAccessToken(user.getEmail(), user.getRole().name());
            String refreshToken = jwtUtil.createRefreshToken(user.getEmail(), user.getRole().name());
    
            Cookie refreshTokenCookie = new Cookie("refresh-token", refreshToken);
            response.setHeader("access-token", accessToken);
            response.addCookie(refreshTokenCookie);
    
            return new ResponseEntity<>(user, HttpStatus.OK);
        }
    }
    • 유효한 access token 로그인

      필터에서 인증객체를 만든다.
      컨트롤러에서 인증객체를 꺼내 토큰을 만들어 발급한다.

    • 만료된 access token, 유효한 refresh token 로그인

      필터에서 refresh token으로 인증객체를 만든다.
      위와 마찬가지로 컨트롤러에서 인증객체를 꺼내 토큰을 만들어 발급한다.

    • 유효하지 않은 access token과 refresh token

      컨트롤러에서 사용자의 아이디 비밀번호를 검증한다. 올바른 정보인 경우 토큰 발급, 그렇지 않은 경우 예외를 던진다.