티스토리 뷰

반응형

Before

[Spring Boot] Spring Security JWT 로그인 구현 (1)

 

[Spring Boot] Spring Security JWT 로그인 구현 (1)

JWT 로그인 구현하기 Spring Boot 3.0 부터 Spring Security 6.0.0 이상의 버전이 적용되면서, Spring Boot 3.1.2 와 Spring Security 6.1.2를 사용하여 JWT 로그인을 구현하고 그 과정을 정리 하였다. [변경사항] WebSecurit

bright-forward.tistory.com

 

JWT 발급하기

 로그인 진행시 AccessToken과 RefreshToken을 발급 받고,

AccessToken이 만료되면 RefreshToken을 통해 AccessToken을 재발급 받을 수 있도록 구현하였다.

 RefreshToken은 Redis를 통해 관리하여 만료시간이 되면 삭제될 수 있도록 하였고,

RefreshToken으로 AccessToken을 재발급 받을 경우 두 Token을 모두 재발급 받고 Redis를 새로 갱신하여 RefreshToken 탈취로 인한 비정상적인 로그인을 방지하였다.

 

토큰 발급은 AccessToken, RefreshToken 동일하게 하였고, 만료 시간만 다르게 설정해 주었다.

public String createAccessToken(Authentication authentication) {
    String authorities = authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));

	// 현재시간에 만료시간을 더해 유효기간을 설정
    long now = (new Date()).getTime();
    Date validity = new Date(now + this.accessExpirationTime);

    return Jwts.builder()
            .setSubject(authentication.getName())
            .claim(AUTHORITIES_KEY, authorities)
            .signWith(key, SignatureAlgorithm.HS512)
            .setExpiration(validity)
            .compact();
}

 

RefreshToken은 재발급 될 때 마다 Redis repository를 갱신하여 RefreshToken으로 AccessToken을 새로 발급시 Redis에 있는 값과 일치하지 않으면 이전 로그인이 탈취된 것으로 간주하고 사용자를 재로그인 하도록 유인하여 RefreshToken 탈취를 예방하기 위해 RefreshToken이 재발급될 때 마다 기존 Redis에 있던 RefreshToken 정보를 덮어쓰게 하였다.

public String createRefreshToken(Authentication authentication) {
    String authorities = authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));

    long now = (new Date()).getTime();
    Date validity = new Date(now + this.refreshExpirationTime);

    String refreshToken = Jwts.builder()
            .setSubject(authentication.getName())
            .claim(AUTHORITIES_KEY, authorities)
            .signWith(key, SignatureAlgorithm.HS512)
            .setExpiration(validity)
            .compact();

    Long expirationTime = getExpiration(refreshToken);

	// RefreshToken이 재발급 될 때 마다 갱신
    tokenService.saveRefreshToken(authentication.getName(), refreshToken, expirationTime);

    return refreshToken;
}

TokenProvider.java

@Component
@Slf4j
@RequiredArgsConstructor
public class TokenProvider implements InitializingBean {

    private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
    private final CustomUserDetailsService userDetailsService;
    private static final String AUTHORITIES_KEY = "auth";

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.token.access-expiration-time}")
    private long accessExpirationTime;

    @Value("${jwt.token.refresh-expiration-time}")
    private long refreshExpirationTime;
    private Key key;
    private final TokenService tokenService;

    // 빈이 생성되고 주입을 받은 후에 secret값을 Base64 Decode해서 key 변수에 할당하기 위해
    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }


    public String createAccessToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        Date validity = new Date(now + this.accessExpirationTime);

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }

    public String createRefreshToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        Date validity = new Date(now + this.refreshExpirationTime);

        String refreshToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();

        Long expirationTime = getExpiration(refreshToken);

        tokenService.saveRefreshToken(authentication.getName(), refreshToken, expirationTime);

        return refreshToken;
    }

    public String getUsernameFromToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public Authentication getAuthentication(String token) {
        logger.info("[getAuthentication] 토큰 인증 정보 조회 시작");
        CustomUserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsernameFromToken(token));

        return new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities());
    }

    public Long getExpiration(String token) {
        // accessToken 남은 유효시간
        Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration();
        // 현재 시간
        long now = new Date().getTime();
        return (expiration.getTime() - now);
    }

    // token 유효성 체크
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {

            logger.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {

            logger.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {

            logger.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {

            logger.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}

Redis로 Token 관리하기

 JWT 로그인을 구현하면서 Redis를 2가지 목적으로 사용하였다.

1. AccessToken 로그아웃 처리

2. RefreshToken 탈취 예방

이렇게 두가지 종류를 관리해야하기 때문에 RedisHash를 사용하였다.

 

Entity

AccessToken.java

@RedisHash(value = "accessToken")
@Getter
@RequiredArgsConstructor
public class AccessToken {

    @Id
    private String token;

    private String logout;

    @TimeToLive(unit = TimeUnit.MILLISECONDS)
    private Long expiration;

    public AccessToken(final String accessToken, Long expirationTime) {
        this.logout = "logout";
        this.token = accessToken;
        this.expiration = expirationTime;
    }
}

 로그아웃할 경우 token의 만료시간 만큼 저장하고, 로그아웃 처리 되어있는 token으로 요청 시 차단할 수 있도록,

logout 변수에는 logout 했다는 의미로 문자열을 담았고, expiration에는 토큰의 남아있는 시간을 저장하도록 설계하였다.

 

RefreshToken.java

@RedisHash(value = "refreshToken")
@Getter
@RequiredArgsConstructor
public class RefreshToken {

    @Id
    private String email;

    private String token;

    @TimeToLive(unit = TimeUnit.MILLISECONDS)
    private Long expiration;

    public RefreshToken(String email, final String refreshToken, Long expiration) {
        this.email = email;
        this.token = refreshToken;
        this.expiration = expiration;
    }
}

 RefreshToken은 email값을 key값으로 지정하여 email을 통해 저장되어있는 refreshToken을 찾을 수 있게 했고, AccessToken과 마찬가지로 expiration을 통해 만료시간 이후에는 삭제되게 하였다.

 

Repository

두 entity 모두 CrudRepository를 상속받은 repository 인터페이스를 만들어 주어 저장 및 조회 가능 하도록 하였다.

@Repository
public interface AccessTokenRepository extends CrudRepository<AccessToken, String> {

}
@Repository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {

}

 

Service

@Service
@RequiredArgsConstructor
public class TokenService {

    private final RefreshTokenRepository refreshTokenRepository;
    private final AccessTokenRepository accessTokenRepository;

    /**
     * REFRESH TOKEN 관리 Service
     */
    public void saveRefreshToken(String email, String refreshToken, Long expiration) {
        RefreshToken rt = new RefreshToken(email, refreshToken, expiration);
        refreshTokenRepository.save(rt);
    }

    public String getRefreshTokenById(String email) {
        RefreshToken rt = refreshTokenRepository.findById(email)
                .orElseThrow(() -> new NullPointerException("기록이 존재하지 않습니다."));

        return rt.getToken();
    }

    public void deleteRefreshTokenById(String email) {
        refreshTokenRepository.deleteById(email);
    }

    /**
     * ACCESS TOKEN 관리 Service
     */
    public void saveLogoutList(String accessToken, Long expiration) {
        AccessToken at = new AccessToken(accessToken, expiration);

        accessTokenRepository.save(at);
    }

    public Boolean checkLogoutList(String accessToken) {
        return accessTokenRepository.existsById(accessToken);
    }
    
}

 

JWT Filter 설정

if(accessToken != null) { // -------------[1]
    if(tokenProvider.validateToken(accessToken)) {	// -------------[2]
        if(Boolean.FALSE.equals(tokenService.checkLogoutList(accessToken))){	// -------------[3]
            Authentication authentication = tokenProvider.getAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.info("=========[Access 토큰 인증 완료]=========");
        } else {	// -------------[4]
            log.info("=========[재로그인 요청]=========");
        }
    } else if (!tokenProvider.validateToken(accessToken) && refreshToken != null) { // -------------[5]
        log.info("=========[Access 토큰 만료 감지]=========");
        Authentication authentication = tokenProvider.getAuthentication(refreshToken);

        boolean validateRefreshToken = tokenProvider.validateToken(refreshToken);

        String redisRefreshToken;

        try {
            redisRefreshToken = tokenService.getRefreshTokenById(authentication.getName());
        } catch (NullPointerException e) {
            redisRefreshToken = null;
        }

        if(!refreshToken.equals(redisRefreshToken)) {	// -------------[6]
            tokenService.deleteRefreshTokenById(authentication.getName());
            log.info("=========[토큰 탈취 감지 로그아웃 처리]=========");

            //TODO: 로그아웃 처리 (세션 만료)
            Long expiration = tokenProvider.getExpiration(accessToken);
            tokenService.saveLogoutList(accessToken, expiration);

        } else if (validateRefreshToken) {	// -------------[7]
            String newAccessToken = tokenProvider.createAccessToken(authentication);
            String newRefreshToken = tokenProvider.createRefreshToken(authentication);

            httpServletResponse.setHeader(JwtAuthenticationFilter.AUTHORIZATION_HEADER, BEARER_PREFIX + newAccessToken);
            httpServletResponse.setHeader(JwtAuthenticationFilter.REFRESH_TOKEN_HEADER, BEARER_PREFIX + newRefreshToken);
            log.info("=========[토큰 재발급 완료]=========");

            Authentication newAuthentication = tokenProvider.getAuthentication(newAccessToken);
            SecurityContextHolder.getContext().setAuthentication(newAuthentication);
        }
    }
}

[1] AccessToken 존재 확인

[2] AccessToken 유효성 체크

[3] Logout한 기록이 있는 AccessToken인지 확인

[4] Logout한 기록이 있는 AccessToken이라면, 재로그인 요청

[5] AccessToken 만료시, RefreshToken 존재 유무와 유효성 체크

[6] Redis에 저장되어 있는 RefreshToken과 일치하는지 확인

   일치하지 않으면, 이전 로그인에서 토큰이 탈취된 것으로 간주하고 로그아웃처리

[7] RefreshToken 유효성 체크

 

JwtAuthenticationFilter.java

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String REFRESH_TOKEN_HEADER = "RefreshToken";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;
    private final TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws IOException, ServletException {

        // 1. Request Header 에서 토큰을 꺼냄
        String accessToken = resolveAccessToken(httpServletRequest);
        String refreshToken = resolveRefreshToken(httpServletRequest);
        
        String requestURI = httpServletRequest.getRequestURI();
        log.info("Request Url: " + requestURI);

        // 2. validateToken 으로 토큰 유효성 검사
        if(accessToken != null) {

            if(tokenProvider.validateToken(accessToken)) {
                if(Boolean.FALSE.equals(tokenService.checkLogoutList(accessToken))){
                    Authentication authentication = tokenProvider.getAuthentication(accessToken);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                    log.info("=========[Access 토큰 인증 완료]=========");
                } else {
                    log.info("=========[재로그인 요청]=========");
                }
            } else if (!tokenProvider.validateToken(accessToken) && refreshToken != null) {
                log.info("=========[Access 토큰 만료 감지]=========");
                Authentication authentication = tokenProvider.getAuthentication(refreshToken);

                boolean validateRefreshToken = tokenProvider.validateToken(refreshToken);

                String redisRefreshToken;

                try {
                    redisRefreshToken = tokenService.getRefreshTokenById(authentication.getName());
                } catch (NullPointerException e) {
                    redisRefreshToken = null;
                }

                if(!refreshToken.equals(redisRefreshToken)) {
                    tokenService.deleteRefreshTokenById(authentication.getName());
                    log.info("=========[토큰 탈취 감지 로그아웃 처리]=========");

                    //TODO: 로그아웃 처리 (세션 만료)
                    Long expiration = tokenProvider.getExpiration(accessToken);
                    tokenService.saveLogoutList(accessToken, expiration);

                } else if (validateRefreshToken) {
                    String newAccessToken = tokenProvider.createAccessToken(authentication);
                    String newRefreshToken = tokenProvider.createRefreshToken(authentication);

                    httpServletResponse.setHeader(JwtAuthenticationFilter.AUTHORIZATION_HEADER, BEARER_PREFIX + newAccessToken);
                    httpServletResponse.setHeader(JwtAuthenticationFilter.REFRESH_TOKEN_HEADER, BEARER_PREFIX + newRefreshToken);
                    log.info("=========[토큰 재발급 완료]=========");

                    Authentication newAuthentication = tokenProvider.getAuthentication(newAccessToken);
                    SecurityContextHolder.getContext().setAuthentication(newAuthentication);
                }
            }
        }

        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }

    // Request Header 에서 Access 토큰 정보를 꺼내오기
    private String resolveAccessToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

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

    // Request Header 에서 Refresh 토큰 정보를 꺼내오기
    private String resolveRefreshToken(HttpServletRequest request) {
        String refreshToken = request.getHeader(REFRESH_TOKEN_HEADER);
        if(StringUtils.hasText(refreshToken) && refreshToken.startsWith(BEARER_PREFIX)) {
            return refreshToken.substring(7);
        }
        return null;
    }
}

 

Security Filter에 Jwt Filter등록

					... 생략 ...
                    
@Bean
protected SecurityFilterChain config(HttpSecurity http) throws Exception {
    http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))        //cors 설정 filter에 적용
            .headers(c -> c.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable).disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .formLogin(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(authorize ->
                    authorize
                            .dispatcherTypeMatchers(DispatcherType.ERROR, DispatcherType.ASYNC, DispatcherType.FORWARD).permitAll()
                            .requestMatchers(AUTH_WHITELIST).permitAll()        // 모두 허용
                            .requestMatchers("/v1/admin/**").hasRole("ADMIN")        //Role 값이 ADMIN인 계정에만 권한 부여
                            .anyRequest().authenticated()
            )
            .addFilterBefore(
            	new JwtAuthenticationFilter(tokenProvider, tokenService),
                UsernamePasswordAuthenticationFilter.class
            );

    return http.build();
}

					... 생략 ...

UsernamePasswordAuthenticationFilter 직전에 JwtAuthenticationFilter가 걸리도록 합니다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
반응형