코드 그라데이션

JWT 서비스 구현하기 본문

SpringBoot [예제] 블로그 만들기/시큐리티, JWT 입히기

JWT 서비스 구현하기

완벽한 장면 2023. 10. 19. 15:05

절차

의존성, 토큰 제공자 추가 -> 리프레시 토큰 도메인, 토큰 필터 추가

 

여기서 만든 클래스들은 다음 장 OAuth에서 사용한다.

 

의존성 추가하기

build.gradle

dependencies {
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    implementation 'javax.xml.bind:jaxb-api:2.3.1'

    testAnnotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.projectlombok:lombok'
}

 


토큰 제공자 추가하기

1. 이슈 발급자, 비밀키 설정

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: // 추가
  issuer: ejwaytogo@gmail.com
  secret_key: study-springboot

 

2. JwtProperties - 해당 값들을 변수로 접근하는 데 사용할 클래스 생성

config.jwt.JwtProperties.java

@Getter
@Setter
@Component
@ConfigurationProperties("jwt")
public class JwtProperties {

    private String issuer; // 발급자
    private String secretKey; // 비밀 키
}

 

3. TokenProperties - 토큰 생성, 유효성  검사, 토큰에서 필요한 정보를 가져오는 클래스

config.jwt.TokenProvider.java

@Service
@RequiredArgsConstructor
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);
    }


    // 1. JWT 토큰 생성 메서드
    private String makeToken(Date expiry, User user) {
        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // 헤더 typ : JWT
                .setIssuer(jwtProperties.getIssuer()) // 내용 iss : 이메일, properties 파일에서 설정한 값.
                .setIssuedAt(now) // 내용 iat : 현재 시간
                .setExpiration(expiry) // 내용 exp : expiry 멤버 변숫값
                .setSubject(user.getEmail()) // 내용 sub : 유저 이메일
                .claim("id", user.getId()) // 클레임 id : 유저 ID
                // 서명 : 비밀값과 함께 해시값을 HS256 방식으로 암호화
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }


    //2. Jwt 토큰 유효성 검증 메서드
    public boolean validToken(String token) {
        try {
            Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey()) // 비밀값으로 복호화
                    .parseClaimsJws(token);
            return true;
        } catch (Exception e) { // 복호화 과정에서 에러가 나면 유효하지 않은 토큰
            return false;
        }
    }

    //3. 토큰 기반으로 인증 정보를 가져오는 메서드
    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);
    }


    // 4. 토큰 기반으로 유저 ID를 가져오는 메서드
    private Claims getClaims(String token) {
        // 클레임 조회
        return Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)
                .getBody();
    }

}

 

[부연설명]

토큰을 생성하는 메서드.

인자는 만료 시간, 유저 정보를 받는다. 이 메서드에서는 set계열의 메서드를 통해 여러 값을 지정한다.

 

헤더는 typ(타입), 내용은 iss(발급자), iat(발급일시), exp(만료일시), sub(토큰 제목), 클레임에는 유저 ID를 지정한다.

토큰을 만들 때는 프로퍼티즈 파일에 선언해둔 비밀값과 함께 HS256 방식으로 암호화한다.

 

 

토큰이 유효한지 검증하는 메서드.

properties 파일에 선언한 비밀값과 함께 토큰 복호화를 진행한다.

만약 복호화 과정에서 에러가 발생하면 유효하지 않은 토큰이므로 false를 반환하고,

아무 에러도 발생하지 않으면 true를 반환한다.

 

 

토큰을 받아 인증 정보를 담은 객체 Authentication을 반환하는 메서드.

properties 파일에 저장한 비밀 값으로 토큰을 복호화한 뒤 클레임을 가져오는 private 메서드인 getClaims()를 호출해서 클레임 정보를 반환받아 사용자 이메일이 들어 있는 토큰 제목 sub와 토큰 기반으로 인증 정보를 생성한다.

 

이때 UsernamePasswordAuthenticationToken의 첫 인자로 들어가는 User

프로젝트에서 만든 User 클래스가 아닌, 스프링 시큐리티에서 제공하는 객체인 User 클래스를 임포트해야 한다.

 

 

토큰 기반으로 사용자 ID를 가져오는 메서드.

properties 파일에 저장한 비밀값으로 토큰을 복호화한 다음 클레임을 가져오는 private 메서드인 getClaims()를 호출해서 클레임 정보를 반환받고 클레임에서 id 키로 저장된 값을 가져와 반환한다.

 

4. 테스트 코드 작성해서 테스트

1) JwtFactory.java

- JWT 토큰 서비스를 테스트 하는 데 사용할 mocking용 객체

@Getter
public class JwtFactory {

    // JWT 토큰의 "subject" 클레임(claim)에 사용될 값을 저장하는 필드.
    private String subject = "test@email.com";

    // JWT 토큰의 "issuedAt" 클레임에 사용될 값을 저장하는 필드.
    // 현재 시간으로 초기화 된다.
    private Date issuedAt = new Date();

    // JWT 토큰의 "expiration" 클레임에 사용될 값을 저장하는 필드.
    // 현재 시간에서 14일 뒤의 시간으로 초기화 된다.
    private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis());

    // JWT 토큰의 추가 클레임을 저장하는 Map 필드.
    // 초기값은 빈 Map.
    private Map<String, Object> claims = emptyMap();


    @Builder
    public JwtFactory(String subject, Date issuedAt, Date expiration,
                      Map<String, Object> claims) {
        // 생성자 매개변수로 전달된 값이 null이 아니면 해당 값으로 필드를 초기화하고,
        // null이면 기본값을 사용한다.
        this.subject = subject != null ? subject : this.subject;
        this.issuedAt = issuedAt != null ? issuedAt : this.issuedAt;
        this.expiration = expiration != null ? expiration : this.expiration;
        this.claims = claims != null ? claims : this.claims;
    }

    // 기본값을 가진 JwtFactory 객체를 생성하는 정적 메서드.
    public static JwtFactory withDefaultValues() {
        return JwtFactory.builder().build();
    }

    // 주어진 JwtProperties와 함께 JWT 토큰을 생성하는 메서드.
    public String createToken(JwtProperties jwtProperties) {
        return Jwts.builder()
                .setSubject(subject) // 토큰의 subject 클레임 설정
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // JWT 타입 설정
                .setIssuer(jwtProperties.getIssuer()) // 토큰 발급자(issuer) 설정
                .setIssuedAt(issuedAt) // 토큰 발급 시간 설정
                .setExpiration(expiration) // 토큰 만료 시간 설정
                .addClaims(claims) // 추가 클레임 설정
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) 
                // 서명 알고리즘을 HS256로 설정
                
                .compact(); // JWT 문자열로 변환하여 반환
    }
}

 

2) TokenProviderTest

개요

2-1.generateToken() : 토큰 생성 메서드

 

2-2. validToken_invalidToken()

 

2-3. validToken_validToken()

 

2-4. getAuthentication() : 토큰을 전달받아 인증 정보를 담은 객체 Authentication을 반환

 

2-5. getUserId() : 토큰 기반으로 User Id를 가져오는 메서드를 테스트

 

실행 결과 확인


 

 

리프레시 토큰 도메인 추가하기

1. 관련 클래스 구현

[구조]

 

1) domain.RefreshToken.java

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@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;
    }
    /**
     * 주어진 코드에서 update 메서드 내부에서 사용되고 있는 this 키워드는 현재 인스턴스를 나타냅니다.
     * 이 메서드는 주로 객체 내의 필드 또는 속성을 업데이트하는 데 사용됩니다.
     *
     * 여기서 update 메서드는
     * newRefreshToken 매개변수로 전달된 새로운 리프레시 토큰 값을 사용하여 refreshToken 필드를 업데이트합니다.
     * this.refreshToken = newRefreshToken;라인은
     * 현재 RefreshToken 객체의 refreshToken 필드를 newRefreshToken 값으로 설정하는 역할을 합니다.
     *
     * 그리고 마지막으로 return this; 문장은 메서드가 현재 객체 자체를 반환함을 의미합니다.
     */
}

 

2) repository.RefreshTokenRepository.java

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByUserId(Long userId);
    Optional<RefreshToken> findByRefreshToken(String refreshToken);
}

 


토큰 필터 구현하기 - 인증 정보 설정하는 클래스

  • 필터는 실제로 각종 요청이 요청을 처리하기 위한 로직
  • 전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공
  • 요청이 오면 헤더값을 비교해서 토큰이 있는지 확인하고, 유효한 토큰이면 security context holder에 인증 정보를 저장한다.

[그림]

 

시큐리티 컨텍스트(security context)

  • 인증 객체가 저장되는 보관소.
  • 여기서 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내 사용할 수 있다.
  • 이 클래스는 스레드마다 공간을 할당하는, , 스레드 로컬(thread local)에 저장되므로 코드의 아무 곳에서나 참조할 수 있고,다른 스레드와 공유하지 않으므로 독립적으로 사용할 수 있다.

시큐리티 컨텍스트 홀더(security context holder)

  • 이러한 시큐리티 컨텍스트 객체를 저장하는 객체

 

 

config.TokenAuthenticationFilter.java

- 이 필터는 액세스 토큰값이 담긴 Authorization 헤더값을 가져온 뒤 액세스 토큰이 유효하다면 인증 정보를 설정

@RequiredArgsConstructor
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 {


        // 요청 헤더의 Authorization 키의 값 조회
        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’인 필드의 값을 가져온 다음 토큰의 접두사 Bearer를 제외한 값을 얻는다. 
만약 값이 null이거나 Bearer로 시작하지 않으면 null을 반환한다. 

이어서 가져온 토큰이 유효한지 확인하고, 유효하다면 인증 정보를 관리하는 시큐리티 컨텍스트에 인증 정보를 설정한다. 


위에서 작성한 코드가 실행되며 인증 정보가 설정된 이후에 컨텍스트 홀더에서 getAuthenticationO 메서드를 사용해 인증 정보를 가져오면 유저 객체가 반환된다. 

 

유저 객체에는 유저 이름(username)과 권한 목록(authorities)과 같은 인증 정보가 포함됩니다.

 

 

728x90
Comments