고을마을 : 나의 코딩 이야기

항해99 7주차 WIL[JWT] 본문

항해99 7기/WIL(Weekly I Learned

항해99 7주차 WIL[JWT]

고을마을 2022. 6. 26. 02:40

2022.6.26 항해 7주차.

목요일부터 실전프로젝트를 하게됐다.

 

우리 조는 Scatch : 슥캐치를 만들고자 한다.

 

캐치마인드처럼 그림그리면서 퀴즈를 맞추는 형식의 웹게임이다.

 

우선적으로 백엔드가 구현해야할 기능으로는,

jwt를 활용한 로그인 회원가입, oAuth를 통한 로그인기능(카카오, 구글, 네이버)

소켓, stomp, sockJS를 통한 채팅구현이다.

 

이중에서 내가 담당하게 된 건 jwt를 활용한 로그인 회원가입, oAuth를 통한 로그인기능(카카오, 구글, 네이버)이다.

미니프로젝트, 클론코딩 주차에 로그인기능을 만들어보지 못했고, 이 기능에 대해 관심이 많았었다.

 

 jwt에 대한 기초적인 학습부터 진행했다.

 


 

@Component
public class TokenProvider implements InitializingBean {
   private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
   private static final String AUTHORITIES_KEY = "auth";
   private final String secret;
   private final long tokenValidityInMilliseconds;
   private Key key;

   public TokenProvider(
      @Value("${jwt.secret}") String secret,
      @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
      this.secret = secret;
      this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
   }

   @Override
   public void afterPropertiesSet() {
      byte[] keyBytes = Decoders.BASE64.decode(secret);
      this.key = Keys.hmacShaKeyFor(keyBytes);
   }

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

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

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

   public Authentication getAuthentication(String token) {
      Claims claims = Jwts
              .parserBuilder()
              .setSigningKey(key)
              .build()
              .parseClaimsJws(token)
              .getBody();

      Collection<? extends GrantedAuthority> authorities =
         Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());

      User principal = new User(claims.getSubject(), "", authorities);

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

  
   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;
   }

 

토큰의 생성, 토큰의 유효성 검증을 담당할 Token Provider

initializingBean을 implements해서 afterPropertiesSet을 오버라이드 한 이유는
빈이 생성되고 의존성 주입을 받은 다음에 시크릿값을 base64 decode해서 key변수에 할당하기 위함.

Authentication객체의 권한정보를 이용해서 토큰을 생성하는 createToken 메소드 추가.
토큰을 파라미터로 받아서 토큰에 담겨있는 정보를 이용해 authentication 객체를 리턴하는 메소드 생성
토큰을 파라미터로 받아서 토큰으로 클레임을 만들고 이를 이용해 유저 객체를 만들어서 최종적으로 Autentication 객체를 리턴.

유효성 검증을 위해 validatetoken 메소드 추가
토큰을 파라미터로 받아서 파싱해보고 발생하는 익셉션을 캐치, 문제가 있으면 false, 정상이면 true를 리턴.

 

 

public class JwtFilter extends GenericFilterBean {
   private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
   public static final String AUTHORIZATION_HEADER = "Authorization";
   private TokenProvider tokenProvider;
   public JwtFilter(TokenProvider tokenProvider) {
      this.tokenProvider = tokenProvider;
   }


   @Override
   public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
      throws IOException, ServletException {
      HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
      String jwt = resolveToken(httpServletRequest);
      String requestURI = httpServletRequest.getRequestURI();


      if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
         Authentication authentication = tokenProvider.getAuthentication(jwt);
         SecurityContextHolder.getContext().setAuthentication(authentication);
         logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
      } else {
         logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
      }

      filterChain.doFilter(servletRequest, servletResponse);
   }

   private String resolveToken(HttpServletRequest request) {
      String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
      if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
         return bearerToken.substring(7);
      }
      return null;
   }

 

jwt를 위한 커스텀 필터를 만들기 위해 jwtfilter 클래스 생성.
genericFilterBean을 extends하고 doFilter Overrice, 실제 필터링 로직은 doFilter 내부에 작성.
jwt 토큰은 TokenProvider를 주입받음.

dofilter는 토큰 인증정보를 securitycontext에 저장하는 역할을 수행.
jwt 토큰은 TokenProvider를 주입받음.

valiidateToken을 통해서 유효성 검증을 마치고
토큰이 정상적이면 Autentication 객체를 받아와서 SecurityContext에 셋해준다.

Request Header에서 토큰 정보를 꺼내오기 위한 resolveToken 메소드 추가.

 

 

public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private TokenProvider tokenProvider;

    public JwtSecurityConfig(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

 

Jwt시큐리티컨피그는 SecurityConfigurerAdapter를 extends하고 TokenProvicer를 주입.
configure 메소드를 오버라이드 해서 jwt필터를 시큐리티 로직에 필터를 등록.

 

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

   @Override
   public void commence(HttpServletRequest request,
                        HttpServletResponse response,
                        AuthenticationException authException) throws IOException {
      response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
   }

 

유효한 자격증명을 제공하지 않고 접근하려할때 401 unauthorized 에러를 리턴할 JwtAuthenticationEntryPoint 생성

SC_UNAUTHORIZED(401)

 

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

   @Override
   public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
      response.sendError(HttpServletResponse.SC_FORBIDDEN);
   }

 

필요한 권한이 존재하지 않은 경우에 403 forbidden 에러를 리턴하기 위해서 JwtAccessDeniedHandler 생성.

SC_FORBIDDEN(403)

 


 

로그인기능 무사히 잘 구현해냈으면 좋겠다! 
ci/cd와 같은 무중단배포도 맡아서 해봐야지.