본문 바로가기
Backend/Spring

Spring boot에서 JWT 구현(2)

by 나는 유찌 2022. 2. 20.

2022.02.19 - [Backend/Spring] - Spring boot에서 JWT 구현(1)

이전 글

 

전 글에서 JWT에 대한 개념을 대충 정리를 했다면 이번에는 구현에 대해서 말해보겠습니다😃

 

 

JWT 로직


  • 클라이언트에서 로그인을 합니다.
  • 로그인 성공 시 Access token과 Refresh token을 만듭니다. 여기서 Access token은 만료시간을 30분으로 지정하고 Refresh token은 만료시간을 2주로 설정합니다. 
  • Refresh token은 Redis에 저장을 하고 클라이언트에게는 Access token을 보내줍니다.
  • 로그인한 유저는 헤더에 Access token을 가지고 다니며 API 통신을 할 때 서버에서 만료된 토큰인지 체크를 합니다.
  • 만료된 토큰일 경우 Access token이 만료되었음을 알려주고 클라이언트는 재발급 API를 요청합니다.
  • 저장소에 저장된 Refresh Token이 만료가 되지 않았으면 Access token을 재발급을 하고 클라이언트에게 보내줍니다.

 

 

 

Refresh Token을 사용하는 이유 및 Redis를 사용하는 이유?


여기서 Refresh Token과 Redis를 사용하는 이유를 말해보겠습니다!

  • 유저의 Access Token을 누군가가 탈취를 했을 때 악의적인 사용을 막기 위해 Access Token의 만료 시간을 짧게 설정합니다. 예로 30분으로 설정하게 되면 정작 토큰의 주인 유저가 사용을 할 때 30분마다 로그인을 해주어야 한다는 번거로운 점이 생기게 됩니다. 이를 위해 만료시간이 보다 긴 Refresh Token을 생성하고 저장소에 보관함으로써 Access Token이 만료되면 재발급을 하는 구조로 사용자의 번거로움을 없애줍니다.
  • 이때 Access Token 만료로 재발급을 받는 API의 동작이 느려지면 당연히 안되겠죠? 때문에 Redis와 같은 Key-Value 형태의 가벼운 저장소를 사용함으로써 성능이 느려지지 않도록 합니다.

 

사실 제 코드는 발급된 Access Token을 localstorage에 저장을 하는 코드입니다🥲....

localstorage에 토큰을 저장하는 로직은 보안 측면에서 좋지 않아 권장하지 않습니다ㅠㅠ

구현을 하면서도 고민이었던 부분인데 나중에 이런 보안적인 측면까지 고려하여 글을 추가하도록 하겠습니다🥲🥲..

 

그럼 일단 Spring boot에서 코드를 어떻게 작성했는지 아래에서 확인해보겠습니다❗

 

 

 

설정값 구현


🛠 Mac OS, Spring boot 2.4.0, Java8을 사용하였습니다. 🛠

 

<!-- Spring Security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- JWT -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

<!-- REDIS -->
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>2.6.0</version>
</dependency>
  • Spring Security, Jwt, Redis와 관련된 dependency를 pom.xml에 추가하고 메이븐 업데이트를 진행합니다.

 

####################JWT######################
jwt.security.key=JWT
jwt.response.header=Authorization
jwt.token.prefix=Bearer
#################################################


#######################REDIS#####################
spring.redis.host=127.0.0.1
spring.redis.port=6379
#################################################

properties 파일에 다음과 같이 추가합니다.

  • jwt secret key는 원래는 공개되어서는 안됩니다! 저는 혼자 만들어보는 코드이니 대충 JWT라고 적어보겠습니다.
  • jwt header는 토큰을 발급받은 클라이언트가 API를 요청할 때 Header에 담아 보낼 이름입니다.
  • prefix는 이전 글에 설명하였던 인증 타입과 관련하여 'Authorization: Bearer <token>'처럼 보내고 받기 위한 값입니다.
  • Redis는 기본 설정 값 그대로 가져왔습니다.

 

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();

        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}

Redis 설정하기 위한 코드입니다.

 

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Value("${jwt.response.header}")
    private String jwtHeader;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:3000")
                .exposedHeaders(jwtHeader) // 'Authorization' 헤더 값을 받아온다
                .allowedMethods(
                        HttpMethod.GET.name(),
                        HttpMethod.POST.name(),
                        HttpMethod.DELETE.name(),
                        HttpMethod.PUT.name(),
                        HttpMethod.OPTIONS.name()
                )
                .allowCredentials(true);
    }
}
  • 저는 RestAPI를 사용했기 때문에 Cors 설정이 필요했습니다.
  • 참고하셔야 될 부분은 exposedHeaders 부분으로 API 요청 시 Header에 'Authorization' 이름의 값은 받아온다는 뜻입니다.

 

import com.blog.api.server.common.Role;
import com.blog.api.server.handler.CustomAccessDeniedHandler;
import com.blog.api.server.handler.CustomAuthenticationEntryPoint;
import com.blog.api.server.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.WebSecurityConfigurerAdapter;
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.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    UserService userService;

    @Autowired
    private TokenProvider tokenProvider;

    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

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

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable() // 기본 로그인 창 X
                .cors()
                .and()
                .csrf().disable() // Restful API 방식 사용으로 disable 처리
                .formLogin().disable() // 위와 같음
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반이므로 세션 사용 안함
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler())
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class); // 꼭 필요한 코드!
    }
}
  • addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)는 API 요청 시에 후자의 Filter보다 JwtAuthenticationFilter를 먼저 실행하라는 뜻입니다. 이 코드를 통해 모든 API 통신에 Access token이 존재하는지와 만료되었는지를 체크합니다.

그럼 저 tokenProvider를 구현하러 가봅시다 ❗

 

import com.blog.api.server.model.Member;
import com.blog.api.server.model.Token;
import com.blog.api.server.repository.RedisRepository;
import com.blog.api.server.utils.JwtUtil;
import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
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 org.springframework.util.StringUtils;

import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.util.*;

@Component
public class TokenProvider {

    @Value("${jwt.security.key}")
    private String secretKey;
    @Value("${jwt.response.header}")
    private String jwtHeader;
    @Value("${jwt.token.prefix}")
    private String jwtTokenPrefix;
    private long accessTokenValidTime = Duration.ofMinutes(30).toMillis(); // 만료시간 30분
    private long refreshTokenValidTime = Duration.ofDays(14).toMillis(); // 만료시간 2주

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private RedisRepository redisRepository;

   
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public Token createAccessToken(Member member) {
        return createToken(member, accessTokenValidTime);
    }

    public Token createRefreshToken(Member member) {
        return createToken(member, refreshTokenValidTime);
    }

    public Token createToken(Member member, long tokenValidTime) {
        Claims claims = Jwts.claims().setSubject(member.getEmail());
        claims.put("roles", member.getRole());
        Date now = new Date();

        String token = Jwts.builder()
                .setClaims(claims) // 정보
                .setIssuedAt(now) // 토큰 발행 시간
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // 토큰 만료 시간
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return Token.builder()
                .key(member.getEmail())
                .value(token)
                .expiredTime(tokenValidTime)
                .build();
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        HashMap<String, String> payloadMap = JwtUtil.getPayloadByToken(token);
        UserDetails userDetails = userDetailsService.loadUserByUsername(payloadMap.get("sub"));
        return new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities());
    }

    // 토큰으로 회원 정보 조회
    public String getUserEmail(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // GET ACCESS TOKEN BY HEADER
    public String resolveAccessToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(jwtHeader);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(jwtTokenPrefix)) {
            return bearerToken.substring(7);
        }
        return null;
    }

    // 토큰의 유효 및 만료 확인
    public boolean validateToken(String token, HttpServletRequest request) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return true;
        } catch(SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature");
            return false;
        } catch(UnsupportedJwtException e) {
            log.error("Unsupported JWT token");
            return false;
        } catch(IllegalArgumentException e) {
            log.error("JWT token is invalid");
            return false;
        }
    }

    public void setHeaderAccessToken(HttpServletResponse response, String accessToken) {
        response.setHeader(jwtHeader, accessToken);
    }
}

토큰을 생성하고 만료되었는지 확인하고 header로부터 값을 가져오는 코드들이 저장되어 있습니다.

주석을 참고해주세요!

 

import com.blog.api.server.common.ErrorCode;
import com.blog.api.server.common.ResponseVO;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

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

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;

    public JwtAuthenticationFilter(TokenProvider provider) {
        tokenProvider = provider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            String path = request.getServletPath();

            if(path.startsWith("/api/auth/reissue")) { // 토큰을 재발급하는 API 경우 토큰 체크 로직 건너뛰기
                filterChain.doFilter(request, response);
            } else {
                String accessToken = tokenProvider.resolveAccessToken(request);
                boolean isTokenValid = tokenProvider.validateToken(accessToken, request);

                if (StringUtils.hasText(accessToken) && isTokenValid) {
                    this.setAuthentication(accessToken);
                }

                filterChain.doFilter(request, response);
            }

        } catch (ExpiredJwtException e) {
            ResponseVO responseVO = ResponseVO.builder()
                    .status(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getStatus())
                    .message(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getMessage())
                    .code(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getCode())
                    .build();

            response.setStatus(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getStatus().value());
            response.getWriter().write(new ObjectMapper().writeValueAsString(responseVO));
            response.getWriter().flush();
        }
    }

    // SecurityContext에 Authentication 저장
    private void setAuthentication(String token) {
        Authentication authentication = tokenProvider.getAuthentication(token);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

모든 API가 실행되기 전에 위의 Filter 코드가 실행이 됩니다.

  • Access token을 재발급하는 API에는 토큰을 체크하는 로직을 태우지 않습니다.
  • 헤더로부터 가져온 Access token이 유효한지 체크를 하고 만일 유효하다면 SecurityContextHolder에 저장합니다.
  • ExpiredJwtException은 체크하는 토큰이 만료가 된 경우의 Exception입니다. Filter에서 발생하는 Exception은 Custom 할 수가 없습니다🥲.. HttpServletResponse에 Access token 만료를 알리는 Custom 400 에러를 담아 보내줍니다.

 

 

 

로그인 구현


@PostMapping("/signin")
public ResponseEntity<ResponseVO> signin(@RequestBody Member memberDTO, HttpServletResponse response) {
    MemberDTO member = userService.signin(memberDTO, response);

    return ResponseEntity
            .status(HttpStatus.OK)
            .body(ResponseVO.builder()
                .status(HttpStatus.OK)
                .response(member)
                .build());
}

 

@Autowired
TokenProvider tokenProvider;

@Autowired
RedisRepository redisRepository;


@Override
public MemberDTO signin(Member memberDTO, HttpServletResponse response) {
    Optional<Member> member = userRepository.findById(memberDTO.getEmail());

    member.orElseThrow(
        () -> new CustomException(ErrorCode.NOT_FOUND_MEMBER)
    );

    // 보내온 비밀번호가 데이터베이스에 저장된 비밀번호와 일치하는지 확인합니다
    if(!passwordEncoder.matches(memberDTO.getPassword(), member.get().getPassword())) {
        throw new CustomException(ErrorCode.NOT_FOUND_MEMBER);
    }

    Token accessToken = tokenProvider.createAccessToken(member.get());
    Token refreshToken = tokenProvider.createRefreshToken(member.get());

    tokenProvider.setHeaderAccessToken(response, accessToken.getValue());

    redisRepository.save(refreshToken);

    return MemberDTO.builder()
            .accessToken(accessToken.getValue())
            .member(member.get())
            .build();
}
  • 존재하지 않는 이메일, 일치하지 않는 비밀번호의 경우 에러를 보내주고 로그인이 성공한 경우 Access token과 Refresh token을 발급합니다.
  • Redis에 Refresh token 객체를 보내 저장을 하고 Access token과 로그인 한 유저의 정보를 담아 보내줍니다.
  • 유저 정보에 패스워드가 담기는데 해당 부분은 수정해주시면 좋을 것 같습니다😅

 

import com.blog.api.server.model.Token;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface RedisRepository extends CrudRepository<Token, String> {
    Optional<Token> findByKey(String key);
}
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.index.Indexed;

@Getter
@Setter
@RedisHash("auth")
@NoArgsConstructor
public class Token {

    @Id
    @Indexed
    private String key;
    private String value;
    @TimeToLive
    private Long expiredTime;

    @Builder
    public Token(String key, String value, Long expiredTime) {
        this.key = key;
        this.value = value;
        this.expiredTime = expiredTime;
    }
}
  • Redis Repository와 Token 객체입니다. 참고해주세요😀

 

테스트


로그인 API를 실행시키고 받은 return 값은 다음과 같습니다.

{
    "status": "OK",
    "message": null,
    "response": {
        "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ5dWppbjEyMy5raW1AZ21haWwuY29tIiwicm9sZXMiOiJNQVNURVIiLCJpYXQiOjE2NDUzMzIxNTMsImV4cCI6MTY0NTMzMzk1M30.WLhWPGX8Uga6k8S2_I9qL2rChqGMPCWXfRuexvc0X6A",
        "member": {
            "email": "yujin123.kim@gmail.com",
            "name": "ㅎㅎ",
            "role": "MASTER",
            "authorities": [
                {
                    "authority": "MASTER"
                }
            ],
            "username": null,
            "accountNonExpired": false,
            "accountNonLocked": false,
            "credentialsNonExpired": false,
            "enabled": false
        }
    },
    "code": null
}

 

Redis에 저장된 Refresh Token

  • Redis에 Refresh Token이 제대로 저장되었는지 확인합니다
  • value 값으로 access 토큰과는 또 다른 토큰 값이 저장되어 있는데 이 값이 Refresh 토큰입니다❗

 

 

 

 

참고


 

JWT에서 Refresh Token은 왜 필요한가?

개인 프로젝트 중 JWT를 사용하는 SimpleTodoList 에서는 회원가입 후 로그인 시 아래처럼 JWT를 발급해준다.이 토큰은 애플리케이션 전반에서 사용자를 인증하는데 사용된다. 기존의 세션과는 달리

velog.io

 

 

다음글


2022.02.20 - [Backend/Spring] - Spring boot에서 JWT 구현(3)