본문 바로가기

Spring Security

Spring Security를 활용한 JWT 로그인 구현(Access Token, Refresh Token)

이번 포스팅에서는 Spring Security를 활용하여 폼 로그인 방식이 아닌 Rest Full API로 이루어진 환경에서 JWT 토큰을 이용하여 로그인 후 인증, 인가하는 방법을 공부해보려고 합니다.

 

1. JWT

1 - 1. JWT란?

  • JWT는 Json Web Token의 줄임말로 Json형식으로 데이터를 주고 받는 토큰을 의미합니다.
  • JWT는 3개의 구조로 되어있는데, 헤더(Header), 내용(Payload), 서명(Signature)로 되어있습니다.

1) 헤더(Header) : 헤더는 토큰의 유형과 서명 알고리즘이 명시됩니다.

{
  "alg": "HS512",  // 해싱 알고리즘 지정
  "typ": "JWT"     // 타입을 지정
}

 

2) 내용(Payload) : 토큰에 담을 정보를 의미하는데, 정보 하나를 Claim이라고 부르고 Json(Key / Value) 형식으로 되어있습니다.

3) 서명(Signature) : 서명(Signature)은 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드입니다. 서명은 위에서 만든 헤더(Header)와 페이로드(Payload)의 값을 각각 BASE64로 인코딩하고, 인코딩한 값을 비밀 키를 이용해 헤더(Header)에서 정의한 알고리즘으로 해싱을 하고, 이 값을 다시 BASE64로 인코딩하여 생성합니다.

1 - 2. JWT의 사용 이유

기존 로그인 방식은 쿠키와 세션을 사용하여 인증하는 방식이였습니다.

하지만, 쿠키 방식의 경우 브라우저에서 쿠키에 담은 정보를 강제로 수정하여 보낼 경우 다른 사용자의 데이터를 변경할 위험이 있습니다. 이런 점을 보완한 로그인 방식이 세션을 이용한 방식이 있는데, 세션의 경우 서버에서 세션ID를 관리하기 때문에 사용자가 많을수록 서버의 부담이 가게되는 단점이 있습니다.

 

JWT방식은 사용자 인증에 필요한 정보를 토큰 자체에 넣어두기 때문에 세션과 같이 별도와 저장관리소가 필요없고, 쿠키로 전달하지 않아도 되기 때문에 쿠키를 사용함으로써 발생하는 취약점도 없어지게 되어 널리 사용하는 방식입니다.

 

1 - 3. JWT 동작원리

  1. 클라이언트에서 ID / PW로 로그인 요청을 합니다.
  2. 서버에서는 ID / PW 검증이 되면, 로그인 진행후에 Access Token과 Refresh Token을 클라이언트에 전달해줍니다.
  3. 클라이언트는 Access Token과 Refresh Token을 저장 공간에 저장해두고, API 요청시 Access Token을 헤더에 담아 Access Token의 만료시간동안 서버에서 허용한 API에 요청을 보낼 수 있습니다.
  4. 만약 클라이언트에서 보낸 Access Token의 만료시간이 지난다면, 서버에서는 Access Token이 만료 되었다는 응답을 전달해줍니다.
  5. 만료 응답을 받은 클라이언트는 Access Token을 재발급 받기위해 저장 공간에 저장해두었던, Refresh Token을 서버에 전달해줍니다.
  6. 서버에서 Refresh Token 검증 후, Refresh Token의 만료기간이 3일 이내이면 Access Token과 Refresh Token을 클라이언트에 전달해주고, Refresh Token의 만료기간이 3일 이상이면, Access Token만 클라이언트에 전달해줍니다.

 

2. JWT Token 로그인 예제

2 - 1. JWT 라이브러리 추가

implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

 

2 - 2. JWT 관련 코드

2 - 2 - 1. Security Config

SecurityConfig파일은 Form Login과 구분하기위해 SelcurityConfig2라고 명시하였습니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig2 extends WebSecurityConfigurerAdapter {

    private final TokenProvider tokenProvider;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .httpBasic().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(new JwtTokenFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
                .authorizeRequests()
                .antMatchers("/question/*").authenticated()
                .anyRequest().permitAll();
        http
                .exceptionHandling()
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UnAuthorized");
                    }
                })
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                        response.sendError(HttpServletResponse.SC_FORBIDDEN);
                    }
                });

        http.headers().frameOptions().disable();
    }
}

 

 

2 - 2 - 2. JwtTokenFilter

OncePerRequestFilter는 매번 들어갈때마다 체크해주는 필터라고 생각하시면 됩니다.

@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 1. Request Header 에서 토큰을 꺼냄
        String jwt = resolveToken(request);

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.split(" ")[1].trim();
        }
        return null;
    }
}

 

2 - 2 - 3. TokenProvider

Token 생성 및 유효성체크 등 Token을 사용할 때, 필요한 기능들을 정리해 놓은 클래스입니다.

@Component
@Slf4j
public class TokenProvider {
    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일
    private static final long THREE_DAYS = 1000 * 60 * 60 * 24 * 3;  // 3일

    private final Key key;

    public TokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenDto createAccessToken(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 151621022 (ex)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(null)
                .build();
    }

    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 151621022 (ex)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    public boolean refreshTokenPeriodCheck(String token) {
        Jws<Claims> claimsJws = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        long now = (new Date()).getTime();
        long refresh_expiredTime = claimsJws.getBody().getExpiration().getTime();
        long refresh_nowTime = new Date(now + REFRESH_TOKEN_EXPIRE_TIME).getTime();

        if (refresh_nowTime - refresh_expiredTime > THREE_DAYS) {
            return true;
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

 

2 - 3. 코드 구현(Controller, Service, Entity 등..)

2 - 3 - 1. Controller 구현

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final AuthService authService;

    @PostMapping("/signup")
    public ResponseEntity<UserResponseDto> signup(@RequestBody JoinRequest joinRequest) {
        return ResponseEntity.ok(authService.signup(joinRequest));
    }

    @PostMapping("/login")
    public ResponseEntity<TokenDto> login(@RequestBody LoginRequest loginRequest) {
        return ResponseEntity.ok(authService.login(loginRequest));
    }

    @PostMapping("/reissue")
    public ResponseEntity<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
        return ResponseEntity.ok(authService.reissue(tokenRequestDto));
    }
}

2 - 3 - 2. Service 구현

@Service
@RequiredArgsConstructor
public class AuthService {
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;

    @Transactional
    public UserResponseDto signup(JoinRequest joinRequest) {
        if (userRepository.existsByLoginId(joinRequest.getLoginId())) {
            throw new RuntimeException("이미 가입되어 있는 유저입니다");
        }

        Users user = joinRequest.toEntity(passwordEncoder.encode(joinRequest.getPassword()));
        return UserResponseDto.of(userRepository.save(user));
    }

    @Transactional
    public TokenDto login(LoginRequest loginRequest) {
        // 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.getLoginId(), loginRequest.getPassword());

        // 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
        //    authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

        // 4. RefreshToken 저장
        RefreshToken refreshToken = RefreshToken.builder()
                .key(authentication.getName())
                .value(tokenDto.getRefreshToken())
                .build();

        refreshTokenRepository.save(refreshToken);

        // 5. 토큰 발급
        return tokenDto;
    }

    @Transactional
    public TokenDto reissue(TokenRequestDto tokenRequestDto) {
        // 1. Refresh Token 검증
        if (!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
            throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
        }

        // 2. Access Token 에서 Member ID 가져오기
        Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());

        // 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴
        RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
                .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));

        // 4. Refresh Token 일치하는지 검사
        if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
            throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
        }

        // 5. 새로운 토큰 생성
        TokenDto tokenDto = null;
        if (tokenProvider.refreshTokenPeriodCheck(refreshToken.getValue())) {
            // 5-1. Refresh Token의 유효기간이 3일 미만일 경우 전체(Access / Refresh) 재발급
            tokenDto = tokenProvider.generateTokenDto(authentication);

            // 6. Refresh Token 저장소 정보 업데이트
            RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
            refreshTokenRepository.save(newRefreshToken);
        } else {
            // 5-2. Refresh Token의 유효기간이 3일 이상일 경우 Access Token만 재발급
            tokenDto = tokenProvider.createAccessToken(authentication);
        }

        // 토큰 발급
        return tokenDto;
    }
}

 

2 - 3 - 3. Refresh Token Entity 구현

@Getter
@NoArgsConstructor
@Entity
public class RefreshToken {
    @Id
    @Column(name = "rt_key")
    private String key;

    @Column(name = "rt_value")
    private String value;

    @Builder
    public RefreshToken(String key, String value) {
        this.key = key;
        this.value = value;
    }

    public RefreshToken updateValue(String token) {
        this.value = token;
        return this;
    }
}

 

 

2 - 3 - 4. TokenDto 구현

@Data
@Builder
@AllArgsConstructor
public class TokenDto {

    private String grantType;
    private String accessToken;
    private String refreshToken;
    private Long accessTokenExpiresIn;
}

2 - 3 - 5. TokenRequestDto 구현

@Data
public class TokenRequestDto {
    private String accessToken;
    private String refreshToken;
}

 

3. 결과

3 - 1. 로그인 성공(Access Token 및 Refresh Token 발급)

3 - 2. API 요청(Header에 "Authorization"을 담지 않고 게시물 저장 요청 보냈을 경우)

 

 

3 - 3. API 요청(Header에 "Authorization"을 담고 게시물 저장 요청 보냈을 경우)

게시글의 id가 정상적으로 노출됨

3 - 4. API 요청(Header에 잘못된 "Authorization"을 담고 게시물 저장 요청 보냈을 경우)

 

3 - 5. Access Token이 만료되어 서버에 Refresh Token을 전달하여 새로운 Access Token을 발급

  • Refresh Token의 만료기간이 3일 이상일 경우 => Access Token만 재발급
  • Refresh Token의 만료기간이 3일 미만일 경우 => Access Token + Refresh Token 재발급