9.2 JWT 서비스 구현

SummerToday·2024년 3월 1일
1
post-thumbnail
post-custom-banner

의존성 추가

// build.gradle

dependencies {
    
      ~ 생략 ~ 
    
    testAnnotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.projectlombok:lombok'
    
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    // 자바 JWT 라이브러리
    implementation 'javax.xml.bind:jaxb-api:2.3.1'
    // XML 문서와 Java 객체 간 매핑을 자동화
}
  • 자바에서 JWT를 사용하기 위한 라이브러리를 추가하고 XML 문서와 자바 객체 간 매핑을 자동화 하는 jax-api를 추가한다.

토큰 제공자 추가

// application.yml

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: true
  datasource:
    url: jdbc:h2:mem:testdb
    username: sa
  h2:
    console:
      enabled: true
jwt:
  issure: qlql7748@gmail.com
  secret_key: ~
  • JWT 토큰을 발급하기 위한 이슈 발급자(issure), 비밀키(secret_key)를 필수로 설정해줘야한다.

// config - jwt - JwtProperties

@Getter
@Setter
@Component
@ConfigurationProperties("jwt") 
public class JwtProperties {
    private String issure;
    private String secretKey;

}
  • 설정 값들을 변수로 접근하는 데 사용할 JwtProperties 클래스를 생성한다.

  • @ConfigurationProperties("property")
    .properties , .yml 파일에 있는 property를 자바 클래스에 값을 가져와서(바인딩) 사용할 수 있게 해주는 어노테이션.


// config - jwt - TokenProvider

@RequiredArgsConstructor
@Service
public class TokenProvider {

    private final JwtProperties jwtProperties;

    public String generateToken(User user, Duration expiredAt) {
        Date now = new Date();
        return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
    }
    
    // JWT 토큰 생성 메서드
    private String makeToken(Date expiry, User user) {
        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(now)
                .setExpiration(expiry)
                .setSubject(user.getEmail())
                .claim("id", user.getId())
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }

    public boolean validToken(String token) {
        try {
            Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey())
                    .parseClaimsJws(token);

            return true;
        } catch (Exception e) {
            return false;
        }
    }


    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token);
        Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));

        return new UsernamePasswordAuthenticationToken(new org.springframework.security.core.userdetails.User(claims.getSubject
                (), "", authorities), token, authorities);
    }

    public Long getUserId(String token) {
        Claims claims = getClaims(token);
        return claims.get("id", Long.class);
    }

    private Claims getClaims(String token) {
        return Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)
                .getBody();
    }
}
  • 토큰의 유효성 검사를 하고, 토큰에서 필요한 정보를 가져오는 클래스 작성.

  • Duration 클래스
    시간 간격을 표현하기 위한 클래스이다. 이 클래스는 시간 간격을 표현하는 데 사용되며, 초, 밀리초, 나노초 등의 단위로 시간을 나타낼 수 있다.

    • Duration 클래스는 다음과 같은 주요 메서드를 제공한다.

      • ofSeconds(long seconds): 지정된 초 단위의 시간 간격을 생성한다.

      • ofMillis(long millis): 지정된 밀리초 단위의 시간 간격을 생성한다.

      • ofNanos(long nanos): 지정된 나노초 단위의 시간 간격을 생성한다.

      • toSeconds(): 시간 간격을 초 단위로 변환한다.

      • toMillis(): 시간 간격을 밀리초 단위로 변환한다.

      • toNanos(): 시간 간격을 나노초 단위로 변환한다.

  • private String makeToken(Date expiry, User user){~}
    expiry 매개변수로 지정된 만료 시간과 user 매개변수로 지정된 사용자 정보를 받아들여 JWT를 생성한다.

    • Jwts.builder()
      JWT를 빌드하기 위한 JwtBuilder 객체를 생성한다.

    • setHeaderParam(Header.TYPE, Header.JWT_TYPE)
      JWT의 헤더에 typ(Header.TYPE) 필드를 JWT로 설정한다.

    • setIssuer(jwtProperties.getIssuer())
      JWT의 발급자를 jwtProperties 객체에서 가져온 값으로 설정한다.

    • setIssuedAt(now)
      JWT의 발급 시간을 현재 시간으로 설정한다.

    • setExpiration(expiry)
      JWT의 만료 시간을 expiry 매개변수로 전달된 값으로 설정한다.

    • setSubject(user.getEmail())
      JWT의 제목을 user 매개변수로 전달된 사용자의 이메일로 설정한다.

    • claim("id", user.getId())
      사용자의 식별자(ID)를 JWT의 비공개 클레임으로 추가한다. 여기서 "id"는 클레임의 키이며, user.getId()는 해당 키에 대한 값으로 설정한다.

    • signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
      JWT를 서명하기 위해 jwtProperties 객체에서 가져온 비밀 키를 사용하고, 서명 알고리즘으로는 HS256 (HMAC SHA-256)을 사용한다.

      • signWith(SignatureAlgorithm algorithm, String key)
        해당 메서드는 JWT를 서명하는 데 사용된다. JWT는 서명되어야만 안전하게 전송 및 저장될 수 있습니다. 서명은 토큰의 내용을 보호하고, 토큰이 변조되지 않았음을 확인하는 데 사용된다.

        다음 두가지 매개변수를 가질 수 있다.

        • SignatureAlgorithm algorithm
          서명 알고리즘을 지정하는 매개변수이다. JWT는 여러 가지 서명 알고리즘을 지원하는데, 대표적으로 HS256 (HMAC SHA-256), RS256 (RSA SHA-256) 등이 있다.
          하지만, 서명 알고리즘은 토큰을 생성하는 동안 사용된 비밀 키의 타입과 일치해야 한다.

        • String key
          서명에 사용될 비밀 키이다. 해당 키는 토큰의 발행자가 가지고 있는 비밀 키이며, 서버 측에서만 알고 있어야 한다.
    • compact()
      JWT는 헤더, 페이로드, 서명 세 부분으로 구성되어 있는데 이 세 부분을 .으로 구분되는 하나의 문자열 형태로 직렬화하여(합쳐) 반환한다.

      ex. ~.~.~. ...


  • public boolean validToken(String token){~}
    JWT 토큰 유효성을 검증하는 메서드이다. 토큰에 이상이 없다면 true를 반환하고, 유효성에 문제가 있을 시 false를 반환한다.

    • Jwts.parser()
      JWT를 파싱하는 JwtParser 객체를 생성

      • 파싱 parsing
        JWT를 파싱하는 과정은 토큰의 구성을 해석하고, 그 내용을 이해하여 필요한 정보를 추출하는 것.
    • .setSigningKey(jwtProperties.getSecretKey())
      JWT의 서명을 검증하기 위해 JwtParser에 JWT를 생성할 때 사용된 비밀 키와 동일한 비밀 키를 설정한다.

    • .parseClaimsJws(token)
      주어진 토큰을 파싱하고, 서명을 확인하여 JWT의 클레임들을 추출하여 토큰의 유효성 검사를 진행한다. 만약 서명이 유효하지 않은 경우, 예외들이 발생한다.


  • public Authentication getAuthentication(String token){~}
    주어진 토큰을 사용하여 사용자의 인증 정보를 생성하고, 해당 토큰을 기반으로 사용자의 정보를 UsernamePasswordAuthenticationToken 객체(Athentication 인터페이스의 구현체) 에 담아 반환하는 메서드이다.

    • 사용자 인증 정보 생성 이유
      사용자를 인증하고, 사용자에게 적절한 권한을 부여하여 시스템의 서비스 및 기능에 접근할 수 있도록 하기 위함이다.
      사용자 인증 정보에는 주로 사용자의 식별자(예: 사용자 ID), 사용자의 비밀번호(또는 비밀번호 해시), 사용자의 권한 등이 포함될 수 있다.
    • getClaims(token)
      주어진 토큰을 파싱하여 그 안에 포함된 클레임들을 추출하는 메서드이다. 이를 통해 JWT의 페이로드에 포함된 사용자 정보를 얻을 수 있다.

    • SimpleGrantedAuthority()
      사용자의 권한을 나타내는 객체이다. 일반적으로 "ROLE_USER", "ROLE_ADMIN"과 같은 문자열 형태의 권한 정보가 매개변수로 들어간다.
      주로 Spring Security에서 사용자의 권한을 표현하고, 인증 및 인가 과정에서 사용된다.

      ROLE_USER : 일반 사용자 권한.
      ROLE_ADMIN : 시스템 관리자 권한.

    • Collections.singleton()
      Collections 유틸리티 클래스에 속해 있는 해당 메서드는 주어진 객체를 포함하는 변하지 않는 단일 항목 집합을 생성한다.

    • set
      Set은 자바 컬렉션 프레임워크의 인터페이스 중 하나로, 순서가 없고 중복된 요소를 허용하지 않는 컬렉션(객체 집합)이다.

    • UsernamePasswordAuthenticationToken()
      해당 클래스는 Athentication 인터페이스의 구현체이고, 사용자의 인증 정보를 나타내는 객체이다.
      사용자가 인증을 하게 되면, 사용자의 이름과 비밀번호를 저장하는 데 사용된다. 이 클래스는 사용자의 인증 과정 중에 생성되어 Authentication 객체로 전환된다.

      • 다음 매개변수들이 존재한다.

        • Principal
          사용자를 나타내는 주요 객체이다. 주로 사용자의 이름(username)이나 식별자를 전달합니다.

        • Credentials
          사용자의 자격 증명을 나타낸다.
          비밀번호(password), 토큰(token) 등의 자격 증명을 전달할 수 있다.

        • Authorities
          사용자의 권한 정보를 나타낸다. 사용자의 권한을 나타내는 GrantedAuthority 객체들의 컬렉션을 전달한다.
          주로 Spring Security에서는 SimpleGrantedAuthority 클래스를 사용하여 GrantedAuthority 객체를 생성하고, 이를 컬렉션으로 관리한다.

    • UsernamePasswordAuthenticationToken(new org.springframework.security
      .core.userdetails.User(claims.getSubject(), "", authorities), token, authorities);
      주어진 토큰, 사용자 정보와 사용자의 권한 정보를 사용하여 사용자의 인증 정보를 생성한다.

      • Principal
        org.springframework.security.core.userdetails.User 클래스를 사용하여 사용자 정보를 나타내는 객체를 생성한다.
        여기서는 claims.getSubject()를 통해 JWT 토큰에서 subject를 가져와 사용자의 이름으로 사용한다.

        • User 클래스
          Spring Security에서 사용자 정보를 담는 클래스이고, 다음 매개변수들을 가지고 있다.

          • username
            사용자명 또는 이메일 주소 등을 나타낸다. 일반적으로는 사용자의 로그인 아이디 또는 식별자를 의미한다. 이는 사용자를 고유하게 식별하는 데 사용된다.

          • password
            사용자의 비밀번호를 나타낸다.보안상의 이유로 일반적으로는 비밀번호의 해시값이나 암호화된 형태가 사용된다. 이를 통해 사용자의 인증을 수행하고 비밀번호를 검증할 수 있다.
            해당 코드에서는 JWT 토큰을 사용하여 인증하므로 비밀번호는 필요하지 않으며, 따라서 비어 있는 문자열을 전달한다.

          • authorities
            사용자의 권한 정보를 나타내는 GrantedAuthority 객체들의 컬렉션이다. 각 GrantedAuthority 객체는 사용자가 가진 권한을 나타내며, 일반적으로는 "ROLE_USER", "ROLE_ADMIN" 등의 문자열을 사용하여 정의된다.
      • Credentials
        토큰을 통해 사용자의 자격증명을 전달한다.

      • Authorities
        ROLE_USER 권한정보를 담고 있는 authorities 객체를 전달한다.


  • public Long getUserId(String token){~}
    토큰 기반으로 유저 ID를 가져오는 메서드이다.

    • claims.get("id", Long.class)
      get() 메서드를 통해 앞서 추출한 클레임에서 "id" 클레임의 값을 Long 타입으로 추출한다.
      • Long.class
        클래스 리터럴(class literal)이다. Long 클래스의 클래스 객체를 나타낸다.

  • private Claims getClaims(String token){~}
    JWT 토큰에서 클레임(claims)을 추출하여 반환하는 메서드이다.

    • Jwts.parser()
      JWT를 파싱하기 위한 JWT 파서(JwtParser)를 생성한다.

    • setSigningKey(jwtProperties.getSecretKey())
      JWT를 검증할 때 사용되는 서명 키를 설정하는 메서드이다.
      jwtProperties.getSecretKey()는 해당 시스템에서 사용하는 JWT의 서명에 사용되는 비밀 키를 가져온다.

    • parseClaimsJws(token)
      주어진 JWT 토큰을 파싱하여 JWT 서명부(Signature)를 확인하고, 토큰에 포함된 클레임들을 추출하는 메서드이다. 해당 메서드는 파싱 결과로 Jws 객체를 반환한다. 이 객체를 통해 JWT의 헤더, 페이로드, 서명 등의 정보에 접근할 수 있다.

    • getBody()
      Jws 객체에서 페이로드를 추출하여 반환한다. 페이로드에는 JWT에 포함된 클레임들이 포함되어 있다.


리프레시 토큰 도메인 구현

리프레시 토큰은 데이터베이스에 저장되는 정보이므로 엔티티와 리포지토리를 추가해줘야한다.

  • 테이블 구조

    • id(BIGINT) : Not Null - 일련번호, 기본키

    • user_id(BIGINT) : Not Null - 유저 ID

    • refresh_token(VARCHAR(255)) : Not Null - 토큰 값
// domain - RefreshToken.java
  
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "user_id", nullable = false, unique = true)
    private Long userId;

    @Column(name = "refresh_token", nullable = false)
    private String refreshToken;

    public RefreshToken(Long userId, String refreshToken) {
        this.userId = userId;
        this.refreshToken = refreshToken;
    }

    public RefreshToken update(String newRefreshToken) {
        this.refreshToken = newRefreshToken;

        return this;
    }
}
// repository - RefreshTokenRepository.java
  
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByUserId(Long userId);
    Optional<RefreshToken> findByRefreshToken(String refreshToken);
}

토큰 필터

토큰 필터는 각종 요청이 요청을 처리하기 위한 로직으로 전달되기 전 후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공한다.
요청이 오면 헤더값(토큰)을 비교하여 토큰이 존재하는지 확인하여, 유효한 토큰이라면 시큐리티 컨텍스트 홀더에 인증 정보를 저장한다. 유효하지 않은 토큰일 시 그에 맞는 로직 처리에 따라 클라이언트에게 응답을 보낸다.

JWT는 HTTP 요청의 Authorization 헤더에 담겨 전송된다.

출처 : https://velog.io/@suhyun_zip/JWT-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

  • 시큐리티 컨텍스트 홀더 (Security Context Holder)
    시큐리티 컨텍스트 객체를 저장하는 객체이다.

  • 시큐리티 컨텍스트 (Security Context)
    인증 객체가 저장되는 보관소이다. 후에 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내 사용할 수 있다. 스레드마다 별도의 시큐리티 컨텍스트가 할당되기 때문에, 한 스레드에서 저장된 인증 객체는 다른 스레드와 공유되지 않는다. 따라서 각 스레드는 자신의 시큐리티 컨텍스트에 저장된 인증 객체를 독립적으로 사용할 수 있다.

    예를 들어, 웹 애플리케이션에서는 각각의 HTTP 요청을 처리하는 스레드마다 사용자의 인증 정보를 시큐리티 컨텍스트에 저장하여 사용할 수 있다. 이렇게 하면 인증 정보를 필요로 하는 서비스나 컨트롤러 등의 다양한 부분에서 스레드마다 독립적으로 사용자의 인증 상태를 확인할 수 있다.

    • 스레드 로컬(Thread Local)
      각각의 스레드에 대해 별도의 저장 공간을 제공하는 기능이다. 이를 통해 각 스레드는 자신만의 데이터를 보관하고, 다른 스레드와는 독립적으로 해당 데이터를 읽고 쓸 수 있다.

토큰 필터 구현

// config - TokenAuthenticationFilter.java 

public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private final TokenProvider tokenProvider;

    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";

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

        String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
        String token = getAccessToken(authorizationHeader);

        if (tokenProvider.validToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String getAccessToken(String authorizationHeader) {
        if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
            return authorizationHeader.substring(TOKEN_PREFIX.length());
        }

        return null;
    }
}  
  • 해당 필터는 액세스 토큰 값이 담긴 Authorization 헤더 값을 가져온 뒤 엑세스 토큰이 유효하다면 인증 정보를 시큐리티 컨텍스트에 저장한다.

  • protected void doFilterInternal extends OncePerRequestFilter (HttpServletRequest request,HttpServletResponse response,FilterChain filterChain) throws ServletException, IOException {~}
    필터링 작업을 수행하는 메서드이다.

    • OncePerRequestFilter
      Spring Framework에서 제공하는 추상 클래스로서 javax.servlet.Filter 인터페이스를 구현하고 있다. 해당 클래스를 상속하여 사용자가 커스텀 필터를 구현할 수 있다.

    • OncePerRequestFilter 클래스 주요 특징

      • 한 번의 요청당 한 번만 실행된다.
        OncePerRequestFilter는 각 HTTP 요청당 한 번만 실행된다. 이를 통해 필터가 여러 번 실행되는 것을 방지하고 필터 체인을 효율적으로 관리할 수 있다.

      • 필터 체인 내에서의 실행 순서
        OncePerRequestFilter는 필터 체인 내에서 특정 위치에 배치할 수 있다. 해당 클래스를 상속하여 구현한 필터는 다른 필터들보다 먼저 실행되거나 나중에 실행될 수 있다.

      • doFilterInternal 메서드 구현
        OncePerRequestFilter를 상속한 필터는 doFilterInternal 메서드를 구현하여 필요한 작업을 수행한다. 해당 메서드는 각 HTTP 요청에 대해 실행되며, 요청을 가로채고 처리하는 역할을 한다.

      • 필터 체인과의 연동
        OncePerRequestFilter는 FilterChain 객체와 함께 동작하여 필터 체인 내에서 요청을 전달한다. 이를 통해 여러 개의 필터들이 순차적으로 실행되고 요청을 처리할 수 있다.
    • throws ServletException, IOException

      • ServletException
        서블릿에서 발생하는 일반적인 예외를 나타낸다. 주로 서블릿이나 필터 등의 웹 컴포넌트에서 사용된다.

      • IOException
        입출력 작업 중에 발생하는 예외를 나타낸다. 주로 파일이나 네트워크와 관련된 입출력 작업에서 사용된다.
    • HttpServletRequest request
      현재 요청에 대한 정보를 담고 있는 HttpServletRequest 객체입니다. 이 객체를 사용하여 클라이언트로부터의 요청을 확인하고 필요한 작업을 수행할 수 있습니다.

    • HttpServletResponse response
      클라이언트로 응답을 보낼 때 사용되는 HttpServletResponse 객체이다. 이 객체를 사용하여 클라이언트에게 응답을 생성하거나 수정할 수 있다.

    • FilterChain filterChain
      현재 필터를 포함한 필터 체인을 나타내는 FilterChain 객체이다. 이 객체를 사용하여 현재 필터 이후의 다음 필터로 요청을 전달할 수 있다.
    • request.getHeader(HEADER_AUTHORIZATION);
      HTTP 요청에서 Authorization 헤더 값을 추출한다. 이 헤더에는 클라이언트가 보낸 토큰이 포함되어 있다.

      • 토큰 형식
        {
        Authorization : Bearer+Token
        }
    • getAccessToken(authorizationHeader)
      추출된 헤더 값을 가지고 getAccessToken 메서드를 호출하여 실제 토큰 값을 가져온다.

    • tokenProvider.validToken(token)
      토큰의 유효성을 확인하여 유효한 경우 true를 반환하고, 그렇지 않은 경우 false를 반환한다.

      일반적으로 토큰의 유효성을 확인하는 과정은 토큰의 서명을 검증하고, 만료 시간을 확인하는 등의 과정을 포함한다. 이를 통해 토큰이 위조되지 않았으며, 유효한 사용자에 의해 발급되었으며, 만료되지 않은 것을 확인할 수 있다.

    • tokenProvider.getAuthentication(token)
      주어진 토큰을 사용하여 사용자를 인증하고, 인증 정보를 담은 Authentication 객체를 생성

    • SecurityContextHolder.getContext().setAuthentication(authentication)
      SecurityContextHolder.getContext()를 이용해 현재 실행 중인 스레드의 보안 컨텍스트를 반환하고, setAuthentication() 메서드를 사용해 현재 실행 중인 스레드의 보안 컨텍스트에 인증 객체를 설정한다.

    • filterChain.doFilter(request, response)
      현재 필터에서 수정된 요청과 응답을 다음 필터에게 전달한다.

      cf. 보통 토큰 필터는 스프링 시큐리티 필터의 가장 앞단에 위치하므로, 토큰 필터는 http 요청을 가로채어 사용자가 제공한 토큰을 검증하고 유효성을 확인하여 인증 필터(AuthenticationFilter)에게 전달한다.




해당 글은 다음 도서의 내용을 정리하고 참고한 글임을 밝힙니다.
신선영, ⌜스프링 부트 3 벡엔드 개발자 되기 - 자바 편⌟, 골든래빗(주), 2023, 384쪽
profile
IT, 개발 관련 정보들을 기록하는 장소입니다.
post-custom-banner

0개의 댓글