[TIL] 순수 JWT 기반 인증·인가 구현

YJin·2025년 4월 29일

[내배캠 Spring 6기_TIL]

목록 보기
29/56
post-thumbnail

프로젝트에서 JWT 기반 인증 인가 구현을 담당하게 되었다. 시간이 충분하지 않아 스프링 시큐리티와 Refresh 토큰 없이, Access 토큰만 가지고 구현하였다.

웹상의 대부분의 자료가 스프링 시큐리티 + Refresh 토큰을 기반으로 하고 있어, 이번처럼 순수 JWT만 사용하는 구조로 직접 구현한 내용을 정리해두려 한다.


JWT 토큰

JWT 토큰 구조


파트내용설명
Headerxxxxx서명 알고리즘, 타입 등
Payloadyyyyy유저 정보, 권한 같은 내용 (Base64로 인코딩)
SignaturezzzzzHeader + Payload를 비밀키로 서명 (변조 방지)

[ 헤더 ]

  • 알고리즘, 토큰 타입

[ 페이로드 ]

  • claim : 페이로드의 구성요소
  • 등록된 클레임(Registered Claim)공개 클레임(Public Claim)비공개 클레임(Private Claim) 으로 나누어지며, key-value 형태로 존재

공개 클레임(Public Claim)

공개 클레임은 사용자 정의 클레임으로, 공개용 정보를 위해 사용된다. 충돌 방지를 위해 URI 포맷을 이용한다.

{
   "https://www.example.com": true}

비공개 클레임(Private Claim)

비공개 클레임은 사용자 정의 클레임으로, 서버와 클라이언트 사이에 임의로 지정한 정보를 저장한다.

{
   "access_token": access
}



JWT 인증 과정

  1. 사용자가 로그인 한다
  2. 서버에서 사용자를 확인한다
  3. 문제 없으면 JWT(Access) 를 발급해서 사용자에게 응답한다
  4. 이후 사용자는 토큰이 유효한 동안 토큰을 헤더에 첨부하여 데이터를 요청한다
  5. 서버는 요청이 들어오면 토큰을 항상 검증한다
  6. 토큰이 유효하면 응답해준다
  7. 만약 토큰이 만료되면 다시 발급해준다



구현 준비

의존성 추가 (build.gralde)

	// JWT
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

application.properties

# jwt-properties
jwt.secret.key=${JWT_SECRET_KEY}
  • JWT 시크릿 키 값 설정

인텔리제이 환경변수 설정

application.properties에서 설정한 JWT 시크릿 키 변수명대로 환경변수를 설정한다.

openssl rand -base64 32
  • 명령어를 사용하여 Base64 인코딩된 일정 크기 이상의 값을 생성해서 넣어준다
  • 윈도우 powershell 에서는 동작하지 않으므로 bash나 git bash 같은 linux shell 에서 사용한다


구현

JwtUtilJwtFilter로 구성된다.

  • JwtUtil : JWT 토큰 생성 및 반환 등 토큰 관련 기능을 처리
  • JwtFilter : 사용자의 JWT 토큰을 가지고 인증 및 인가를 처리

JwtUtil

@Component
@RequiredArgsConstructor
public class JwtUtil {

  // 토큰 유효시간
  private static final Long TOKEN_TIME = 60 * 60 * 1000L;    // 60분
  // Token 식별자
  public static final String BEARER_PREFIX = "Bearer ";     // 관례

  @Value("${jwt.secret.key}")
  private String secretKey;
  private Key key;    // 비밀키(secretKey)를 통해 키 생성

  // 비밀키 알고리즘
  private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

  // 스프링 컨테이너 초기화 이후 1회 초기화 실행
  @PostConstruct
  public void init() {
    byte[] bytes = Base64.getDecoder().decode(secretKey);
    key = Keys.hmacShaKeyFor(bytes);
  }

  // 토큰 발급
  public String createToken(Long userId, String email, Role role) {
    // 페이로드
    Date now = new Date();

    return Jwts.builder()
        .setSubject(String.valueOf(userId))                       // 토큰의 주체
        .setHeaderParam("typ", "JWT")                       // 헤더 설정
        .setIssuedAt(now)                                       // 발행 시간
        .setExpiration(new Date(now.getTime() + TOKEN_TIME))   // 토큰 만료기한 (발급 일시 +60분)
        .claim("userId", userId)                          // Private Claims (Key-Value)
        .claim("email", email)
        .claim("role", role)
        .signWith(key, signatureAlgorithm)   // 서명 (사용 알고리즘, 서명 생성-검증 용 비밀 키)
        .compact();
  }

  public String substringToken(String tokenValue) {
    if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
      return tokenValue.substring(7);
    }
    throw new BaseException(MISSING_TOKEN);
  }

  // 토큰 바디(Claims) 반환
  public Claims extractClaims(String token) {
    return Jwts.parserBuilder().setSigningKey(key).build()
        .parseClaimsJws(token).getBody();
  }

  // 토큰에서 userId 획득
  public Long getUserIdFromToken(String token) {
    return Long.valueOf(Jwts.parserBuilder().setSigningKey(key).build()
        .parseClaimsJws(token).getBody().getSubject());
  }
}

JwtFilter

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

  private final JwtUtil jwtUtil;
  private final AntPathMatcher pathMatcher = new AntPathMatcher();

  // 로그인 필터 제외 대상 URL
  private static final List<String> WHITE_LIST = List.of(
      "/signup",  // 회원가입
      "/login"    // 로그아웃
  );

  // 사장 전용 URL
  private static final List<String> OWNER_LIST = List.of(
      "/owners",
      "/stores/*/categories/**",
      "/reviews/*/comments/**"
  );


  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {
    String url = request.getRequestURI();
    log.info("Request url :" + url);

    // WHITE_LIST 는 필터 적용 제외
    for (String matcher : WHITE_LIST) {
      if (url.startsWith(matcher)) {
        log.info("WHITE LIST REQUEST");
        filterChain.doFilter(request, response);
        return;
      }
    }

    String bearerToken = request.getHeader("Authorization");
    log.info("Authorization Header: {}", bearerToken);

    // 토큰이 비어있다면
    if (bearerToken == null) {
      response.sendError(HttpServletResponse.SC_BAD_REQUEST, "로그인 해주세요.");
      return;
    }

    // 유효한 토큰인지 검사
    String token = jwtUtil.substringToken(bearerToken);
    Claims claims = null;

    try {
      claims = jwtUtil.extractClaims(token);
      // 유저 역할 검증 // TODO: 추후 early return 하도록 변경
      for (String matcher : OWNER_LIST) {
        // 사장만 접근 가능 URL 에
        // if (url.startsWith(matcher)) {
        if (pathMatcher.match(matcher, url)) {
          Role userRole = Role.valueOf(claims.get("role", String.class));

          // 만약 유저 role 이 일반 사용자(USER)라면
          if (!Role.OWNER.equals(userRole)) {
            log.info("JwtFilter Role : Invalid");
            response.sendError(HttpServletResponse.SC_FORBIDDEN, "유효하지 않은 접근입니다.");
            return;
          }
        }
      }

      // request attributes 세팅
      request.setAttribute("userId", Long.valueOf(claims.getSubject()));
      request.setAttribute("role", claims.get("role"));
      request.setAttribute("email", claims.get("email"));

      filterChain.doFilter(request, response);
    } catch (SecurityException | MalformedJwtException | SignatureException e) {
      response.sendError(HttpServletResponse.SC_BAD_REQUEST, "유효하지 않은 JWT 서명입니다.");
    } catch (ExpiredJwtException e) {
      response.sendError(HttpServletResponse.SC_BAD_REQUEST, "만료된 JWT 토큰입니다.");
    } catch (UnsupportedJwtException e) {
      response.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
    } catch (IllegalArgumentException e) {
      response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
    } catch (Exception e) {
      response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰에 문제가 있습니다.");
    }

  }
}



향후 리팩토링

  • 여기서는 Access 토큰만 사용하여 로그아웃이 따로 구현이 안된다 (토큰 만료 기간에만 의존해야 함.)
  • 로그아웃된 토큰을 블랙 리스트로 관리하는 방식으로 (만료시간이 유효한 동안) 보완할 수 있을 것 같음.
  • 스프링 시큐리티+Access/Refresh 토큰까지 사용하는 방식으로
  • 대신 블랙 리스트를 DB나 캐시 저장소로 관리하게 되면, state-less 의 장점이 떨어짐. 이 부분을 어떻게 해야할 지 고민 필요



참고

profile
백엔드 개발도 락이다

0개의 댓글