Notice
Recent Posts
Recent Comments
Link
«   2025/03   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
Archives
Today
Total
관리 메뉴

개발자

[Srping Security + JWT] JWT 로그인/토큰 재발급 동작 flow 본문

개발자/workflow 리팩토링 프로젝트(SpringBoot,JPA,MySQL)

[Srping Security + JWT] JWT 로그인/토큰 재발급 동작 flow

GoGo개발 2023. 9. 12. 17:02

spring security와 jwt 를 이용하여 로그인을 구현하고 토큰 재발급, redis를 활용한 로그아웃, 최신토큰 사용하기 등등 다 구현을 해보았는데요. reids활용한 구현부분은 flow를 정리해놓았는데 jwt를 이용한 로그인와 토급재발급의 동작 flow에 대한

설명이 부족한것 같아서 추가로 포스팅을 작성하려고 합니다.

 

이 포스팅은 jwt를 이용한 로그인과 토큰 재발급의 동작 흐름에 관한 글이기 때문에 구현방법은 이전포스팅들을 참고해주시기 바랍니다.

 

들어가기 앞서 

OncePerRequestFilter가 아니더라도 일반적인 GenericFilterBean을 상속받아도 무관합니다. (OncePerRequestFilter가 이미 GenericFilterBean를 상속받고 있기 때문.)

Filter 작동 방식은 간단합니다. Bearer로 토큰을 받으면 토큰을 추출하여 올바른 토큰인지 체크를 합니다.
토큰이 올바르다면, 토큰에 있는 정보(username, role 등등) 을 가져와 인증을 시킵니다..
그리고 filter 에서 한번 검사하고 난 뒤 requestDispatcher에 의해 다른 요청으로 forward 된다면 또 다시 filter에서 검사할 수 있기 때문에 한 번 들어온 요청에 대해서는 한번만 인증을 거치도록 하는 필터를 구현해야 합니다. 이를 위해서는 OncePerRequestFilter 를 확장하여 구현하면 됩니다. 제가 만들 필터의 이름은 JwtFilter 입니다.

이 JwtTokenProvider는 @Configuration 어노테이션으로 스프링 빈으로 관리되고 있는 ArgumentResolver이나 Interceptor에서 사용되고 있기 때문에 @Component 어노테이션으로 스프링 빈 등록을 해주어야 합니다.

 

자이제  로그인부터 알아봅시다

login flow

설명은 - class [method] 형식

 

1. JwtAuthenticationFilter (OncePerRequestFilter 상속)

2. JwtTokenProvider [resolveToken]  : Request Header에서 JWT 토큰 추출

3. JwtAuthenticationFilter : filter,provider 에서 유효성검사 진행

4. LoginController[login]

5. LoginService[login] UsernamePasswordAuthenticationToken 객체에 대해 아래에서 먼저 알아봅시다.

Login ID/PW를 기반으로 Authentication 객체 생성
UsernamePasswordAuthenticationToken은 추후 인증이 끝나고 SecurityContextHolder.getContext()에 등록될 Authentication 객체이다

 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(request.getMail(), request.getPw());​


이때 authentication 은 인증 여부를 확인하는 authenticated 값이 false이다. 즉, 아직 인증되지 않은 Authentication객체를 생성한 것이고 추후 모든 인증이 완료되면 인증된 생성자로 Authentication객체가 생성된다는 것을 알아두자.

이때  authenticated 값이 false인이유는 아래 블로그를 참고하자
https://cjw-awdsd.tistory.com/45

 

6. LoginService[login] - authenticationManagerBuilder 실제인증 시작

 

 Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);


이때 authenticate 메서드가 실행될 때 EmployeeDetailService에서 만든 loadUserByUsernmae 메서드가 실행된다

 

7. EmployeeDetailsService [loadUserByUsername] - DB에서 회원정보 조회 

 

그렇다면 바로 loadUserByUsername 메소드가 실행되는 이유가 뭘까? 

authenticationManangerBuilder.getObject().authenticate() 메소드가 실행되면

1. AuthenticationManager 의 구현체인 ProviderManager 의 authenticate() 메소드가 실행됩니다

2. 해당 메소드에선 AuthenticaionProvider 인터페이스의 authenticate() 메소드를 실행하는데

해당 인터페이스에서 데이터베이스에 있는 이용자의 정보를 가져오는  UserDetailsService 인터페이스를 사용합니다.

3. 그래서 UserDetailsService 인터페이스의 loadUserByUsername() 메소드를 호출하게 됩니다.

따라서 CustomUserDetailsService 구현체에 오버라이드된 loadUserByUsername() 메소드를 호출하게 되는 것입니다.

 

8. EmployeeDetails - 7번을 해당 클래스로 받는다

9. LoginService - 이제 authentication = true; (인증 완료)

10. JwtTokenProvider [generateToken] : 토큰 발급

11. LoginService - 토큰 발급 완료 return

 

 

 

토큰으로 리소스 접근 flow

 

1. JwtAuthenticationFilter

2. JwtTokenProvider [resolveToken]

3. JwtAuthenticationFilter - 토큰 검증

4. JwtTokenProvider [vaildateToken]

5. JwtTokenProvider [getAuthentication] - UsernamePasswordAuthenticationToken (UserDetail 객체 만들어서 authentication 반환(true)

6. JwtAuthenticationFilter - springSecurity Context에 저장 / authentication = true;

7. 해당 요청 API Contorller

 

 

앞의 포스팅에서도 말했듯이

 

JWT는 발급한 후 삭제가 불가능하기 때문에, 접근에 관여하는 토큰에 유효시간을 부여하는 식으로 탈취 문제에 대해 대응을 하여야 합니다.

하지만 보안상 이슈로 토큰 재사용은 고려해보아야하는데요, 저는 매번 새로운 토큰을 발급해주는 방법으로 했습니다.

 

flow의 이해를 돕기위해 provider와 filter 클래스를 아래에 올려놓겠습니다.

 

package com.workFlow.WFrefactoring.security.config;

import com.workFlow.WFrefactoring.exception.CheckTokenException;
import com.workFlow.WFrefactoring.security.dto.TokenDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

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;
import org.springframework.data.redis.core.RedisTemplate;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate<String, String> redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //1.Request Header 에서 JWT 토큰 추출
        String accessToken = jwtTokenProvider.resolveToken(request);
        //refreash토큰 추출
        String refreshToken = jwtTokenProvider.resolveRefreshToken(request);

        //로그아웃 된 토큰인지 확인
        if(StringUtils.hasText(accessToken)) jwtTokenProvider.validateBlackListToken(accessToken);

        //2.validateToken으로 토큰 유효성 검사(토큰 있다면실행 / login요청 페이지에서는 적용X) (redis 검증도 추가해서 가장 최신 accessToken만 사용가능)
        if(StringUtils.hasText(accessToken) && jwtTokenProvider.vaildateToken(accessToken) ){
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
            // 가장 최근 토큰인지 확인
            String ATK = (String)redisTemplate.opsForValue().get("ATK"+authentication.getName());
            if(!ATK.equals(accessToken)){
                throw new CheckTokenException("최신 토큰을 사용하세요");
            }
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        //accessToken 만료시 refreshToken 검증 (redis 검증도 추가해서 가장 최신 refreshToken만 사용가능)
        else if(StringUtils.hasText(refreshToken) && !jwtTokenProvider.vaildateToken(accessToken) && jwtTokenProvider.vaildateToken(refreshToken)){
            //검증 통과시 refreshToken에서 권한정보 가져오기
            Authentication authentication = jwtTokenProvider.getAuthentication(refreshToken);
            // 가장 최근 토큰인지 확인
            String RTK = (String)redisTemplate.opsForValue().get("RTK"+authentication.getName());
            if(!RTK.equals(refreshToken)){
                throw new CheckTokenException("최신 refresh 토큰을 사용하세요");
            }
            //accessToken,refreshToken 모두 재발급(refreshTokeb 발급마다 redis에 저장해줘서 따로 update 로직 필요X)
            TokenDto tokenDto = jwtTokenProvider.generateToken(authentication);
            //토큰 내려주기
            JwtTokenProvider.setToken(tokenDto);
            //SecurityContext 에 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }

}

 

package com.workFlow.WFrefactoring.security.config;

import com.workFlow.WFrefactoring.exception.BlackListToken;
import com.workFlow.WFrefactoring.security.dto.TokenDto;
import com.workFlow.WFrefactoring.security.service.EmployeeDetailsService;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;


import javax.servlet.http.HttpServletRequest;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    @Value("${jwt.secret}")
    private String secretKey;
    private final RedisTemplate<String, String> redisTemplate;
    private final EmployeeDetailsService employeeDetailsService;


    public String resolveToken(HttpServletRequest request) {
        String token = request.getHeader("Authorization");
        if(StringUtils.hasText(token) && token.startsWith("Bearer")){
            return token.substring(7); // "Bearer "를 뺀 값, 즉 토큰 값
        }
        return null; //throw new IllegalArgumentException("Invalid refresh token");
    }

    public String resolveRefreshToken(HttpServletRequest request){
        String refreshToken = request.getHeader("refreshToken");
        if(StringUtils.hasText(refreshToken) && refreshToken.startsWith("Bearer")){
            return refreshToken.substring(7);
        }
        return null; //throw new IllegalArgumentException("Invalid refresh token");
    }

    //토큰 생성
    public TokenDto generateToken(Authentication authentication) {
        //권한
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        String authorities2 = authentication.getAuthorities().toString();
        String authoritiesName = authentication.getName();

        long timeOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로 변환(시차)

        long accessTokenExpirationTime = 1000*60*30; //30분 1000*60*30 (1000*60은 1분)
        long refreshTokenExpirationTime = 7 * 24 * 60 * 60 * 1000; //7일 7 * 24 * 60 * 60 * 1000

        //Access Token
        String accessToken = Jwts.builder()
                .setSubject(authoritiesName)
                .claim("auth", authorities)
                .setIssuedAt(new Date(System.currentTimeMillis()+timeOffset))
                .setExpiration(new Date(System.currentTimeMillis()+accessTokenExpirationTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
        //log.info("날짜 : "+ String.valueOf(new Date(System.currentTimeMillis()+accessTokenExpirationTime)));

        //redis에 accessToken 저장
        redisTemplate.opsForValue().set(
                "ATK"+authoritiesName,
                accessToken,
                accessTokenExpirationTime,
                TimeUnit.MILLISECONDS
        );

        //Refresh Token
        String refreshToken = Jwts.builder()
                .setSubject(authoritiesName)
                .claim("auth", authorities)
                .setExpiration(new Date(System.currentTimeMillis()+refreshTokenExpirationTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        //redis에 refreshToken 저장
        redisTemplate.opsForValue().set(
                "RTK"+authoritiesName,
                refreshToken,
                refreshTokenExpirationTime,
                TimeUnit.MILLISECONDS
        );

        return TokenDto.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    //토큰으로부터 클레임을 만들고, 이를 통해 User 객체를 생성하여 Authentication 객체를 반환
    //JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    //UsernamePasswordAuthenticationToken으로 보내 인증된 유저인지 확인
    public  Authentication getAuthentication(String accessToken){
        Claims claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(accessToken).getBody();
//        if(claims.get("auth") == null){
//            throw new RuntimeException("권한 정보가 없는 토큰입니다");
//        }
//
//        //권한
//        Collection<? extends GrantedAuthority> authorities =
//                Arrays.stream(claims.get("auth").toString().split(","))
//                        .map(SimpleGrantedAuthority::new)
//                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        //UserDetails principal = new User(claims.getSubject(), "", authorities);
        UserDetails userDetails = employeeDetailsService.loadUserByUsername(claims.getSubject());
        return  new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    //토근 정보를 검증
    public boolean vaildateToken(String token){
        try {
            Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e){
            log.info("invalid JWT Token", e);
        } catch (ExpiredJwtException e){
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e){
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e){
            log.info("JWT claims string is empty", e);
        }
        return false;
    }

    //access토큰 만료시 access,refreshToken 재발급 내려주기
    public static TokenDto setToken(TokenDto tokenDto){
        return tokenDto;
    }

    //JWT 토큰의 만료시간
    public Long getExpiration(String accessToken){
        Date expiration = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(accessToken).getBody().getExpiration();

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

        return expiration.getTime() - now;
    }
    public void validateBlackListToken(String accessToken){
        String blackList = redisTemplate.opsForValue().get(accessToken);
        if(StringUtils.hasText(blackList)){
            throw new BlackListToken("로그아웃된 사용자 입니다");
        }
    }
}

 

중간에 권한을 가져오는 메소드에서 주석처리된 부분이있는데요 contoller에서 인증된 객체를 가져올때 null이 들어오는 문제가 발생하여 수정된 코드입니다. 이 오류에 대한 부분은 추후 포스팅하겠습니다.

 

감사합니다.

 

참고블로그

https://jaewoo2233.tistory.com/72

https://www.inflearn.com/questions/349502/authcontroller%EC%97%90%EC%84%9C-loadbyusername-%EB%A9%94%EC%84%9C%EB%93%9C-%EC%8B%A4%ED%96%89-%EA%B2%BD%EB%A1%9C