Spring boot에서 JWT 구현(2)

2022. 2. 20. 16:13·Backend/Spring

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)

'Backend > Spring' 카테고리의 다른 글

Spring boot에서 권한 Scope 처리를 해보자! (AccessDecisionManager, AccessDecisionVoter)  (0) 2022.10.30
Spring boot에서 JWT 구현(3)  (2) 2022.02.20
Spring boot에서 JWT 구현(1)  (0) 2022.02.19
Spring boot에서 첨부파일 업로드 (Apache Tika 파일 변조 체크)  (0) 2021.11.14
Spring boot에 lucy-xss-servlet-filter 적용하기  (0) 2021.07.11
'Backend/Spring' 카테고리의 다른 글
  • Spring boot에서 권한 Scope 처리를 해보자! (AccessDecisionManager, AccessDecisionVoter)
  • Spring boot에서 JWT 구현(3)
  • Spring boot에서 JWT 구현(1)
  • Spring boot에서 첨부파일 업로드 (Apache Tika 파일 변조 체크)
나는 유찌
나는 유찌
쩌리쨩
  • 나는 유찌
    유찌 개발 일기
    나는 유찌
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 사이드 프로젝트
        • 게시판
        • 블로그(Spring boot + React.js ..
      • 데이터베이스
        • SQLD
      • 이슈 해결
      • Front
        • Javascript
        • Vue.js
        • HTML+CSS
      • Backend
        • Spring
        • ORM
        • JAVA
      • 공부
        • HTTP
        • OOP
        • 이것저것
        • 코딩테스트 | 알고리즘
      • Computer Science
        • Computer architecture
        • 데이터베이스
        • 운영체제
      • 일상
        • 독서
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Access token 재발급
    Spring Boot
    phantom read
    spring 격리수준
    히가시노 게이고
    jwt
    Kotlin AccessDecisionManager
    AntPathMatcher
    권한 scope 처리
    Access Token Refresh Token
    LeetCode
    독서
    mysql 격리수준
    추리소설
    refresh token
    Kotlin AntPathMatcher
    한국소설
    AccessDecisionVoter
    Spring boot에서 JWT 구현
    spring
    redis 분산락
    jwt 로그인 구현
    mssql
    Spring Security AccessDecisionManager
    role scope
    pessimisticlock
    access token
    DIRTY READ
    웹 개발
    JWT이란?
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
나는 유찌
Spring boot에서 JWT 구현(2)
상단으로

티스토리툴바