[DEV] J-Jay

스프링 부트 - JWT 본문

Back-end/Spring

스프링 부트 - JWT

J-Jay 2023. 9. 10. 22:28
728x90

JWT(JSON Web Token) 구현

 

build.gradle 의존성 추가

dependencies {
    //자바 JWT 라이브러리
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    //XML 문서와 Java 객체간 매핑을 자동화 시켜주는 api
    implementation 'javax.xml.bind:jaxb-api:2.3.1'
}

토큰 제공자 추가

 

JWT 토큰을 만들려면 발급자, 비밀키를 필수로 설정해야한다.

 

application.yml

 

jwt:
  issuer: j-jay
  secret_key: springboot-blog

 

JwtProperties.java

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

    private String issuer;
    private String secretKey;
}

해당 값들을 변수로 접근하는데 사용할 JwtProperties 클래스이다.

 

TokenProvider.java

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

    // 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
                .setIssuedAt(now)                     //내용 iat : 현재시간
                .setExpiration(expiry)                //내용 exp : expiry 멤버 변수
                .setSubject(user.getEmail())          //내용 sub : user email
                .claim("id", user.getId())            //클레임 id: user id
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) 
                //서명 : 비밀키 값과 함께 해시값을 H256 방식으로 암호화
                .compact();
    }

    //JWT 토큰 유효성 검증 메서드
    public boolean validToken(String token) {
        try {
            Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey()) //비밀값 복호화
                    .parseClaimsJws(token);

            return true;
        } catch (Exception e) { //복호화 과정에서 오류 Exception 처리
            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);
    }

    //토큰 기반으로 user id를 가져오는 메서드
    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();
    }
}

 

토큰을 생성하고 올바른 토큰인지 유효성 검사하며, 토큰에서 필요한 정보를 가져오는 클래스이다.

 

 

JwtFactory.java (테스트)

 

@Getter
public class JwtFactory {

    private String subject = "test@email.com";

    private Date issuedAt = new Date();

    private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis());

    private Map<String, Object> claims = emptyMap();

    @Builder
    public JwtFactory(String subject, Date issuedAt, Date expiration,
                      Map<String, Object> claims) {
        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;
    }

    public static JwtFactory withDefaultValues() {
        return JwtFactory.builder().build();
    }

    public String createToken(JwtProperties jwtProperties) {
        return Jwts.builder()
                .setSubject(subject)
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(issuedAt)
                .setExpiration(expiration)
                .addClaims(claims)
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }
}

위 코드는 JWT 토큰 서비스를 테스트하는 모킹(mocking)용 객체이다.

여기서 모킹이란 테스트를 실행할 때 객체를 대신하는 가짜 객체를 의미한다.

 빌더 패턴을 사용하여 객체를 만들 때 테스트가 필요한 데이터만 선택하며, 빌더 패턴을 사용하지 않으면 필드 기본값을 사용한다.

 

TokenProviderTest.java

@SpringBootTest
class TokenProviderTest {

    @Autowired
    private TokenProvider tokenProvider;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private JwtProperties jwtProperties;

    @DisplayName("generateToken(): User 정보, 만료기간 전달, Token 생성")
    @Test
    void generateToken() {
        // given
        User testUser = userRepository.save(User.builder()
                .email("user@gmail.com")
                .password("test")
                .build());

        // when
        String token = tokenProvider.generateToken(testUser, Duration.ofDays(14));

        // then
        Long userId = Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)
                .getBody()
                .get("id", Long.class);

        assertThat(userId).isEqualTo(testUser.getId());
    }

    @DisplayName("validToken(): 만료된 Token 인 경우 유효성 검증 실패")
    @Test
    void validToken_invalidToken() {
        // given
        String token = JwtFactory.builder()
                .expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
                .build()
                .createToken(jwtProperties);

        // when
        boolean result = tokenProvider.validToken(token);

        // then
        assertThat(result).isFalse();
    }


    @DisplayName("validToken(): 유효한 Token 인 경우에 유효성 검증 성공")
    @Test
    void validToken_validToken() {
        // given
        String token = JwtFactory.withDefaultValues()
                .createToken(jwtProperties);

        // when
        boolean result = tokenProvider.validToken(token);

        // then
        assertThat(result).isTrue();
    }


    @DisplayName("getAuthentication(): Token 기반으로 인증정보 가져옴")
    @Test
    void getAuthentication() {
        // given
        String userEmail = "user@email.com";
        String token = JwtFactory.builder()
                .subject(userEmail)
                .build()
                .createToken(jwtProperties);

        // when
        Authentication authentication = tokenProvider.getAuthentication(token);

        // then
        assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
    }

    @DisplayName("getUserId(): Token으로 user ID 가져옴")
    @Test
    void getUserId() {
        // given
        Long userId = 1L;
        String token = JwtFactory.builder()
                .claims(Map.of("id", userId))
                .build()
                .createToken(jwtProperties);

        // when
        Long userIdByToken = tokenProvider.getUserId(token);

        // then
        assertThat(userIdByToken).isEqualTo(userId);
    }
}

리프레시 토큰 구현

 

RefreshToken.java

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

 

리프레시 토큰은 데이터베이스에 저장하는 정보이므로 엔티티를 생성한다.

 

RefreshTokenRepository.java

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

리프레시 토큰은 데이터베이스에 저장하는 정보이므로 레포지토리도  추가한다.

 


토큰 필터 구현

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

 

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

Security Context Holder이다.

 

TokenAuthenticationFilter.java

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

        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을 반환한다.

이어서 가져온 토큰의 유효성을 확인하고 유효하다면 인증 정보를 관리하는 Security Context에 인증정보를 설정한다.


토큰 API 구현

 

UserService.java

    public User findById(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
    }

 

전달받은 User Id로 User를 검색해서 전달하는 findById() 메서드를 추가로 구현한다.

 

RefreshTokenService.java

@RequiredArgsConstructor
@Service
public class RefreshTokenService {
    private final RefreshTokenRepository refreshTokenRepository;

    public RefreshToken findByRefreshToken(String refreshToken) {
        return refreshTokenRepository.findByRefreshToken(refreshToken)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected token"));
    }
}

전달받은 리프레시 토큰으로 리프레시 토큰 객체를 검색해서 전달하는 findByRefreshToken() 메서드를 구현한다.

 

TokenService.java

@RequiredArgsConstructor
@Service
public class TokenService {

    private final TokenProvider tokenProvider;
    private final RefreshTokenService refreshTokenService;
    private final UserService userService;

    public String createNewAccessToken(String refreshToken) {
        // 토큰 유효성 검사에 실패하면 예외 발생
        if(!tokenProvider.validToken(refreshToken)) {
            throw new IllegalArgumentException("Unexpected token");
        }

        Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
        User user = userService.findById(userId);

        return tokenProvider.generateToken(user, Duration.ofHours(2));
    }
}

createNewAcessToekn() 메서드는 전달받은 리프레시 토큰으로 토큰 유효성 검사를 진행하고, 유효한 토크인 경우,

리프레시 토큰으로 User ID를 찾는다. 마지막으로 User Id로 user를 찾은 후 토큰 제공자의 generateToken()메서드를 호출하여 새로운 엑세스 토큰을 생성한다.

 

CreateAccessTokenRequest.java (DTO)

@Getter
@Setter
public class CreateAccessTokenRequest {
    private String refreshToken;
}

CreateAcessTokenResponse.java (DTO)

@AllArgsConstructor
@Getter
public class CreateAccessTokenResponse {
    private String accessToken;
}

토큰 생성과 요청 및 응답을 담당할 DTO이다.

 

 

TokenApiController.java

@RequiredArgsConstructor
@RestController
public class TokenApiController {

    private final TokenService tokenService;

    @PostMapping("/api/token")
    public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken(@RequestBody CreateAccessTokenRequest request) {
        String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(new CreateAccessTokenResponse(newAccessToken));
    }
}

실제 요청을 받고 처리할 컨트롤러 이다

/api/token POST 요청이오면 토큰 서비스에서 리프레시 토큰을 기반으로 새로운 엑세스 토큰을 만들어준다.