JAVA

JWT

응디 2022. 11. 30. 16:42

Access token 발급

  1. 사용자가 서버에 접근한다.
  2. JwtAuthenticationFilter 가 JwtTokenProvider를 통해 토큰을 검증한다.
  3. 인증 과정에서 토큰이 비정상적이면 JwtAuthenticationEntryPoint 를 호출하고 인가 과정에서 문제가 생기면 JwtAccessDenidedHandler, 정상적이면 CustomOAuth2UserService 호출한다. 또한 정상적일경우 SecurityContextHolder에 유저정보가 담김(filter 참고)
  4. 토큰이 정상적이면 oauth 로그인 후처리 과정으로 CustomOAuth2UserService 호출 됨
  5. 위에서 OAuth2 로그인이 성공하면 SuccessHandler 실패하면 FailureHandler 가 호출된다.
 

Refresh token 발급

jwt는 토큰 자체에 정보를 담고 있어서 보안이 매우 취약하다.

이를 보완하기 위해서 사용하는 것이 refresh token!

→ AccessToken의 유효기간은 매우 짧게 Refresh Token의 유효기간은 길게 해주는 것이 포인트!

→ AccessToken이 만료됐을 시 refresh token 으로 재발급 받을 수 있다. 이 때 refresh token은 만료가 되지 않았어도 새로 발급해주면 보안 상 더 좋다.

 

장점

  1. Access token의 유효기간을 짧게 잡아 중간 탈취 방지 효과를 얻을 수 있다. → 만약 중간에 탈취 당하더라도 유효기간이 짧아 사용할 수 있는 기간이 줄어들음!
  2. Access Token 의 유효기간이 짧아도 Refresh Token 이 만료될때까지 추가적인 로그인 하지 않아도 됨! → 마치 세션이 유지되는 듯 한 효과

기존의 Spring security 에서는 WebSecurityConfigurerAdapter를 상속받아 configure 메소드를 오버라이딩 해서 구현했었다면, 현재는 WebSecurityConfigurerAdapter가 deprecated , 즉 사용되지 않아 다른 방법을 사용해야한다.

→ 방법 : SecurityFilterChain를 Bean 으로 등록해서 사용해야 한다.

 

Spring Security.java

package com.smtd.smtdApi.config;

import com.smtd.smtdApi.security.jwt.JwtAccessDenidedHandler;
import com.smtd.smtdApi.security.jwt.JwtAuthenticationEntryPoint;
import com.smtd.smtdApi.security.jwt.JwtAuthenticationFilter;
import com.smtd.smtdApi.security.oauth.OAuth2FailureHandler;
import com.smtd.smtdApi.security.oauth.OAuth2SuccessHandler;
import com.smtd.smtdApi.security.oauth.CustomOAuth2UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration // IoC 빈(bean)을 등록
@EnableWebSecurity // 필터 체인 관리 시작 어노테이션
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) // 특정 주소 접근시 권한 및 인증을 위한 어노테이션 활성화
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomOAuth2UserService oAuth2UserService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final OAuth2FailureHandler oAuth2FailureHandler;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDenidedHandler jwtAccessDenidedHandler;

    @Bean
    public WebSecurityCustomizer configure() {
        return (web) ->  web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                .antMatchers("/h2-console/**", "/vendor/**", "/img/**", "/favicon.ico", "/docs/**");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/oauth2/**", "/login", "/auth/**").permitAll()
                .antMatchers("/admin/**").access("hasRole('ADMIN')")
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .httpBasic().disable()      // base auth off
                .cors()
                .and()
                .csrf().disable()           // csrf off
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                .httpBasic().disable()
                .oauth2Login()
                .authorizationEndpoint()    // 소셜 로그인
                .baseUri("/oauth2/authorization")
                .and()
                .redirectionEndpoint().baseUri("/oauth2/code/*")    //소셜 로그인 후 redirect url
                .and()
                .userInfoEndpoint()
                .userService(oAuth2UserService)   // 구글 로그인이 완료된 뒤의 후처리가 필요함. Tip. 코드를 받는게 아님( 액세스 토큰 + 사용자 프로필 정보를 한방에 받음 )
                .and()
                .successHandler(oAuth2SuccessHandler)       // 성공 핸들러
                .failureHandler(oAuth2FailureHandler);      // 실패 핸들러

        http.exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDenidedHandler);

        return http.build();
    }
}

 

JwtAuthenticationFilter.java

package com.smtd.smtdApi.security.jwt;

import com.smtd.smtdApi.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
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;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = parseToken(request);

        // 토큰 유효성 검사
        // 정상이면 해당 토큰으로 Authentication 가져와서 Security 저장
        if(StringUtils.hasText(token) && tokenProvider.validateToken(token)){
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            logger.info(authentication.getName() + "의 인증정보 저장");
        } else {
            logger.info("유효한 토큰이 없습니다.");
        }

        filterChain.doFilter(request, response);

    }

    // 토큰 꺼내오기
    private String parseToken(HttpServletRequest request){
        String bearerToken = request.getHeader("Authorization");

        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){
            return bearerToken.substring(7);
        }

        return null;
    }

}

로그인 시 위의 필터에서 token을 꺼내와서 유효성 체크를 진행한다.

Authentication authentication = tokenProvider.getAuthentication(token)

→ 위의 코드는 인증이 끝나고 SecurityContextHolder.getContext()에 등록 될 Authentication 객체를 받음

( 자세한건 아래 JwtTokenProvider 확인 )

 

JwtTokenProvider.java

package com.smtd.smtdApi.security.jwt;

import com.smtd.smtdApi.repository.MemberRepository;
import com.smtd.smtdApi.security.CustomUserDetails;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Log4j2
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
    @Value("${app.auth.token.secret-key}")
    private String SECRET_KEY;
    public static final Long ACCESS_TOKEN_EXPIRATION_TIME = 1000L * 60 * 60;
    private final Long REFRESH_TOKEN_EXPIRE_LENGTH = 1000L * 60 * 60 * 24 * 7;
    private static final String AUTHORITIES_KEY = "role";

    private final MemberRepository memberRepository;

    // access token 생성
    public String createAccessToken(Authentication authentication) {
        return this.createToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME);
    }
		
		// refresh token 생성
    public String createRefreshToken(Authentication authentication){
        String token = this.createToken(authentication, REFRESH_TOKEN_EXPIRE_LENGTH);
        CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
        String email = user.getEmail();
        memberRepository.updateRefreshToken(email, token);
        return token;
    }

    public String createToken(Authentication authentication, long tokenValidTime){
        CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();

        Date now = new Date();
        Date expireDate = new Date(now.getTime() + tokenValidTime);

        String userId = user.getName();
        String role = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        String token = Jwts.builder()
                .setSubject(userId)
                .claim(AUTHORITIES_KEY, role)
                .setIssuer("smtd")
                .setIssuedAt(new Date())
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY).compact();

        return token;
    }

		// 인증 후 객체 부여
    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token);
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new).collect(Collectors.toList());

        CustomUserDetails user = new CustomUserDetails(1L, claims.getSubject(), authorities);
        return new UsernamePasswordAuthenticationToken(user, "", authorities);

    }

    // 토큰 복호화
    public Claims getClaims(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .parseClaimsJws(token)
                .getBody();

        return claims;
    }

    // 토큰 유효성 검사
    public Boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalStateException e) {
            log.info("JWT 토큰이 잘못되었습니다");
        } catch (SignatureException e) {
            log.info("유효하지 않은 JWT signature 입니다.");
        } catch (MalformedJwtException e) {
            log.info("유효하지 않은 JWT 토큰입니다.");
        }

        return false;
    }

}

만약 위의 과정을 걸쳐 인증이 완료 되면 successHandler 를 거쳐서 accessToken 과 refreshToken이 발급된다. 아래 코드에서는 임시로 발급되는것을 확인 하기 위해 queryParam 으로 발급해주었다. 또한 refresh token은 DB에 저장해 주었다.

 

OAuth2SuccessHandler.java

package com.smtd.smtdApi.security.oauth;

import com.smtd.smtdApi.exception.ApiException;
import com.smtd.smtdApi.exception.ErrorCode;
import com.smtd.smtdApi.security.jwt.JwtTokenProvider;
import com.smtd.smtdApi.utils.CookieUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import java.util.Optional;

import static com.smtd.smtdApi.security.oauth.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME;

@Log4j2
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Value("${app.oauth2.authorizedRedirectUris}")
    private List<String> redirectUris;

    private final JwtTokenProvider jwtTokenProvider;

    private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String targetUrl = determineTargetUrl(request, response, authentication);
        if(response.isCommitted()){
            logger.debug("response has already been commmited. unable to redirect to " + targetUrl);
            return;
        }

        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        super.onAuthenticationSuccess(request, response, authentication);
    }

    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication){
        Optional<String> redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME).map(Cookie::getValue);

        if(redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())){
            throw new ApiException(ErrorCode.NO_EMAIL);
        }

        String targetUri = redirectUri.orElse(getDefaultTargetUrl());
        String accessToken = jwtTokenProvider.createAccessToken(authentication);
        String refreshToken = jwtTokenProvider.createRefreshToken(authentication);
	
        return UriComponentsBuilder.fromUriString(targetUri)
                .queryParam("error", "")
                .queryParam("accessToken", accessToken)
                .queryParam("refreshToken", refreshToken)
                .build().toUriString();
    }

    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }

    private boolean isAuthorizedRedirectUri(String uri){
        URI clientRedirectUri = URI.create(uri);

        for(String redirectUri : redirectUris){
            URI authorizedUri = URI.create(redirectUri);
            if(authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
                && authorizedUri.getPort() == clientRedirectUri.getPort()){
                return true;
            }

        }
        return false;
    }
}

로그인 완료 후 url 로 accessToken을 전달 받았다( header에 넣어주는 것을 권장 함 )

해당 토큰을 미리 만들어 둔 test 에 담아서 보내면 Hello world를 response 받을 수 있다.

만약 잘못된 토큰을 담아 보내면 401 error 가 난다.

자 그렇다면 Access 토큰 만료 시 재발급을 어떻게 해야 할까??

 

필자는 access token 재발급시와 동시에 refresh token도 업데이트 시켜주었다.

좀 더 쉬운 테스트를 위해 accessToken 과 refreshToken을 전달 받아 해당 accessToken 의 만료 유무를 체크하고 만료 되었으면

새로운 token을 발급 , 아직 만료 전이면 null 을 return 하도록 해보았다.

 

AuthService.java

package com.smtd.smtdApi.service;

import com.smtd.smtdApi.repository.MemberRepository;
import com.smtd.smtdApi.security.CustomUserDetails;
import com.smtd.smtdApi.security.jwt.JwtTokenProvider;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

import java.util.Date;

@Log4j2
@Service
@RequiredArgsConstructor
public class AuthService {

    @Value("${app.auth.token.secret-key}")
    private String SECRET_KEY;

    private final MemberRepository memberRepository;
    private final JwtTokenProvider tokenProvider;

    public String refreshToken(String accessToken, String refreshToken) {
        String savedToken = "";
        try {
            tokenProvider.getAuthentication(accessToken);
        } catch (ExpiredJwtException e) {
            Authentication authentication = tokenProvider.getAuthentication(refreshToken);
            CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();

            String email = user.getEmail();

            savedToken = memberRepository.getRefreshTokenByEmail(email);
            if (savedToken.equals(refreshToken)) {
                Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(refreshToken);
                //refresh 토큰의 만료시간이 지나지 않았을 경우, 새로운 access 토큰을 생성합니다.
                Date exp = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(refreshToken).getBody().getExpiration();
                if (!exp.before(new Date())) {
                    tokenProvider.createRefreshToken(authentication);
                    return tokenProvider.createAccessToken(authentication);
                }
            }
        }

        return null;

    }
}

tokenProvider.getAuthentication(accessToken)

: accessToken이 만료 되었으면 ExpiredJwtException을 터뜨릴것이다.

그렇다면 catch 에서 refreshToken 으로 계정 정보를 가지고 있는 Authentication 객체를 가져온다.

 

savedToken = memberRepository.getRefreshTokenByEmail(email)

: 유저의 email 정보를 가져와 해당 이메일의 refresh token을 DB에서 검색해서 가져온다.

 

Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(refreshToken).getBody().getExpiration()

: refreshToken의 만료시간, 전달받은 refreshToken과 DB에서 가져온 값이 일치하면 refreshToken도 만료시간(exp)을 체크한다.

 

만료 시간이 지나지 않았으면 accessToken은 재발급 해 반환해주고 refreshToken은 새로 update 시킨다.

 

 

'JAVA' 카테고리의 다른 글

JUnit5 기본 annotation  (0) 2022.12.14
Homebrew mysql 설치  (0) 2022.12.07
spring boot 프로젝트 생성과 사용 이유  (0) 2022.10.31
google oauth  (0) 2022.10.06
LocalDate 와 LocalTime  (0) 2022.08.25