웹 개발

Spring Boot에서 JWT 사용하는 방법

노루아부지 2022. 4. 10. 21:22

 

 

JWT란?

JWT는 JSON Web Token의 약자로 JSON 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token으로써 Token은 비공개 시크릿 키 또는 공개/비공개 키를 사용하여 서명됩니다. 예를 들어 서버는 "관리자로 로그인됨"이라는 클레임이 있는 토큰을 생성하여 이를 클라이언트에 제공할 수 있습니다.

 

 

 

JWT의 장점

  1. 토큰은 세션과는 달리 서버가 아닌 클라이언트에 저장되기 때문에 서버의 부담을 덜 수 있다.
  2. RESTful과 같은 무상태(Stateless) 환경에서 데이터를 주고받을 수 있다.

 

 

JWT 단점 및 주의사항

  1. 토큰 자체에 정보를 담고 있으므로 민감한 정보는 담으면 안된다. (탈취한 토큰을 공식 홈페이지에 붙여넣기 하면 바로 payload 확인 가능)
  2. 토큰의 길이로 인해 정보가 많아질수록 네트워크에 부하를 줄 수 있다.
  3. JWT는 상태를 저장하지 않기 때문에 한번 만들면 제어가 불가능하다. 즉, 토큰을 임의로 삭제하는 것이 불가능하기 때문에 토큰 만료 시간을 꼭 넣어주어야 한다.

 

 

 

JWT 진행 순서

  1. 클라이언트 사용자가 ID/Password를 통해 사용자 인증
  2. 서버에서 서명된(Signed) JWT를 생성하여 클라이언트에 응답(Response)
  3. 클라이언트가 서버에 데이터를 추가적으로 요구할 때 JWT를 HTTP Header에 첨부
  4. 서버에서 JWT를 검증

JWT는 JSON 데이터를 Base64 URL-safe Encode를 통해 인코딩하여 직렬 화한 것이 포함되며 토큰 내부에는 위변조 방지를 위해 개인키를 통한 전자서명도 있습니다.

 

 

 

JWT 구조

JWT는 다음과 같이 Header(algorithm, token type), Payload(data), Verify signature로 이루어져 있습니다.

 

header . payload . signature

 

 

https://jwt.io/ 캡처

 

 

 

Header

토큰의 헤더는 typ(Token type), alg(algorithm) 두 가지 정보로 구성됩니다. alg는 헤더를 암호화하는 것이 아니고 Signature를 해싱하기 위한 알고리즘을 지정하는 것입니다. 위 이미지에서 보이는 HS256은 이 토큰이 HMAC-SHA256를 사용하여 서명됨을 의미합니다.

 

Payload

Payload에는 토큰에 일반적으로 포함되는 표준 필드인 7개의 등록 클래임을 정의합니다. 또한 토크의 목적에 따라 사용자 지정 클레임 또한 포함합니다.

표준 7 등록 클래임은 다음과 같습니다.

  • iss: 토큰 발급자(issuer)
  • sub: 토큰 제목(subject)
  • aud: 토큰 대상자(audience)
  • exp: 토큰 만료 시간(expiration), NumericDate 형식이어야 함
  • nbf: 토큰 활성 날짜(not before), 이 날이 지나기 전의 토큰은 활성화하지 않음
  • iat: 토큰 발급시간(issued at)
  • jti: JWT 토큰 식별자(JWT ID)

 

Verify signature

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

 

 

 

1. 의존성 추가

build.gradle에 다음과 같이 의존성을 추가합니다.

implementation 'io.jsonwebtoken:jjwt:0.9.1'​

 

 

2. JWT Token Provider 추가

JwtTokenProvider에는 토큰 생성과 토큰의 유효성 검사, 토큰의 복호화 등 토큰에 관련된 함수들을 작성합니다.

package com.example.demo;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;
import java.util.Date;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class JwtTokenProvider {

  private static final String JWT_SECRET = "secretKey";

  // 토큰 유효시간
  private static final int JWT_EXPIRATION_MS = 604800000;

  // jwt 토큰 생성
  public static String generateToken(Authentication authentication) {
    Date now = new Date();
    Date expiryDate = new Date(now.getTime() + JWT_EXPIRATION_MS);

    return Jwts.builder()
        .setSubject((String) authentication.getPrincipal()) // 사용자
        .setIssuedAt(new Date()) // 현재 시간 기반으로 생성
        .setExpiration(expiryDate) // 만료 시간 세팅
        .claim("userId", "admin")
        .claim("userName", "홍길동")
        // 사용할 암호화 알고리즘, signature에 들어갈 secret 값 세팅
        .signWith(SignatureAlgorithm.HS512, JWT_SECRET)
        .compact();
  }

  // Jwt 토큰에서 아이디 추출
  public static String getUserIdFromJWT(String token) {
    Claims claims = Jwts.parser()
        .setSigningKey(JWT_SECRET)
        .parseClaimsJws(token)
        .getBody();

    log.info("id:"+claims.getId());
    log.info("issuer:"+claims.getIssuer());
    log.info("issue:"+claims.getIssuedAt().toString());
    log.info("subject:"+claims.getSubject());
    log.info("Audience:"+claims.getAudience());
    log.info("expire:"+claims.getExpiration().toString());
    log.info("userName:"+claims.get("userName"));

    return claims.getSubject();
  }

  // Jwt 토큰 유효성 검사
  public static boolean validateToken(String token) {
    try {
      Jwts.parser().setSigningKey(JWT_SECRET).parseClaimsJws(token);
      return true;
    } catch (SignatureException e) {
      log.error("Invalid JWT signature", e);
    } catch (MalformedJwtException e) {
      log.error("Invalid JWT token", e);
    } catch (ExpiredJwtException e) {
      log.error("Expired JWT token", e);
    } catch (UnsupportedJwtException e) {
      log.error("Unsupported JWT token", e);
    } catch (IllegalArgumentException e) {
      log.error("JWT claims string is empty.", e);
    }
    return false;
  }
}

 

 

3. 통신 테스트를 진행할 Controller 추가

token을 return할 login 메서드와 token 검증을 테스트할 main 메서드를 추가합니다.

package com.example.demo;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class TestController {

  @RequestMapping("/auth/login")
  public String login(@RequestParam String userId) {
    if(userId.equals("admin")) {

      Authentication authentication = new UserAuthentication(userId, null, null);
      String token = JwtTokenProvider.generateToken(authentication);

      return token;
    }
    return "error";
  }

  @RequestMapping("/main")
  public String main() {
    return "main";
  }
}

 

 

 

4. JwtAuthenticationEntryPoint 클래스 작성

package com.example.demo.security;

import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

  /**
   * 유효한 자격증명을 하지 않고 접근하려 할때 401.
   *
   * @param request
   * @param response
   * @param e
   * @throws IOException
   */
  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException {
    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
  }
}

 

 

 

5. 실제 Token의 유효성 검사를 할 JwtAuthenticationFilter 작성

package com.example.demo;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {

    log.info(request.getRequestURI());

    if(!request.getRequestURI().contains("login") && !request.getRequestURI().contains("favicon")) {
      log.info("토큰 체크");
      try {
        String jwt = getJwtFromRequest(request); //request에서 jwt 토큰을 꺼낸다.
        if (StringUtils.isNotEmpty(jwt) && JwtTokenProvider.validateToken(jwt)) {
          String userId = JwtTokenProvider.getUserIdFromJWT(jwt); //jwt에서 사용자 id를 꺼낸다.

          log.info("userId : " + userId);

          UserAuthentication authentication = new UserAuthentication(userId, null, null); //id를 인증한다.
          authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); //기본적으로 제공한 details 세팅

          SecurityContextHolder.getContext()
              .setAuthentication(authentication); //세션에서 계속 사용하기 위해 securityContext에 Authentication 등록
        } else {
          if (StringUtils.isEmpty(jwt)) {
            request.setAttribute("unauthorization", "401 인증키 없음.");
          }

          if (JwtTokenProvider.validateToken(jwt)) {
            request.setAttribute("unauthorization", "401-001 인증키 만료.");
          }
        }
      } catch (Exception ex) {
        logger.error("Could not set user authentication in security context", ex);
      }
    }

    filterChain.doFilter(request, response);
  }

  private String getJwtFromRequest(HttpServletRequest request) {
    String bearerToken = request.getHeader("Authorization");
    log.info("bearerToken : " + bearerToken);
    if (StringUtils.isNotEmpty(bearerToken) && bearerToken.startsWith("Bearer ")) {
      log.info("Bearer exist");
      return bearerToken.substring("Bearer ".length());
    }
    return null;
  }
}

 

 

6. UserAuthentication 클래스 작성

import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

public class UserAuthentication extends UsernamePasswordAuthenticationToken {

  public UserAuthentication(String principal, String credentials) {
    super(principal, credentials);
  }

  public UserAuthentication(String principal, String credentials,
      List<GrantedAuthority> authorities) {
    super(principal, credentials, authorities);
  }
}

 

 

 

7. Spring Security Configuration 클래스 작성

package com.example.demo.security;

import com.example.demo.JwtAuthenticationFilter;
import java.util.Arrays;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Slf4j
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

  private final JwtAuthenticationEntryPoint unauthorizedHandler;

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
        // (1) 교차출처 리소스 공유(CORS) 설정
        .cors() //(1)
        .and()
        // (2)  CSRF(Cross Site Request Forgery) 사이트 간 요청 위조 설정
        .csrf() //(2)
        .disable()
        // 인증, 허가 에러 시 공통적으로 처리해주는 부분
        .exceptionHandling() //(3)
        .authenticationEntryPoint(unauthorizedHandler)
        .and()
        .sessionManagement() //(4)
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        // UsernamePasswordAuthenticationFilter보다 JwtAuthenticationFilter를 먼저 수행
        .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
        .authorizeRequests() // (5)

        // login, 회원가입 API는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll
        .antMatchers("/auth/**")
        .permitAll()

        // 나머지는 전부 인증 필요
        .antMatchers("/**")
        .authenticated()

        // 시큐리티는 기본적으로 세션을 사용
        // 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
        .and()
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    ;
  }

  @Bean
  public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.addAllowedOrigin("*");
    configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE"));
    configuration.addAllowedHeader("*");
    configuration.setAllowCredentials(true);
    configuration.setMaxAge(3600L);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
  }

  //비밀번호 암호화를 위한 Encoder 설정
  @Bean
  public BCryptPasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

 

 

 

 

동작 확인

동작 확인 postman으로 진행했습니다.

 

1. auth/login?userId=admin을 호출합니다.

 

2. main에 접속합니다.

  • auth/login에서 return된 token을 복사합니다.
  • Header의 Key에 Authorization을 입력하고, Value에 Bearer을 입력한 후 한 칸 띄고 토큰 값을 붙여 넣기 합니다.
  • 이것은 JwtAuthenticationFilter의 getJwtFromRequest에 정의되어 있습니다.
  • 결과가 main이 나타났다면 끝입니다.

 

 

 

 

 

 

 

[참고 사이트]

https://mangkyu.tistory.com/56

https://ko.wikipedia.org/wiki/JSON_%EC%9B%B9_%ED%86%A0%ED%81%B0#cite_note-rfc7519-1

https://brunch.co.kr/@jinyoungchoi95/1

 

 

728x90
loading