Spring Security JWT

김병수·2022년 11월 29일
0
post-thumbnail

세션 기반과 토큰 기반 자격 차이

세션 기반토큰 기반
서버 관리 여부ox
네트워크 트래픽적음많음
보안성유리불리(서버에서 관리x)
확장성불리(세션 불일치 발생 가능)유리
적합한 방식SSRCSR

JWT

JSON Web Token의 약자로 JSON 포맷의 토큰 정보를 인코딩 후, 인코딩 된 토큰 정보를 Secret Key로 서명(Sign)한 메시지를 Web Token으로써 인증 과정에 사용하는 토큰 인증 방식이다. JWT는 보통 액세스 토큰(Access Token) 과 리프레시 토큰(Refresh Token) 두 가지 중류가 사용된다.

  1. Access Token : 보호된 정보들에 접근할 수 있는 권한부여에 사용. 권한을 부여받는 데엔 Access Token만 갖고 있으면 된다. 유효 기간을 짧게 설정한다.
  2. Refresh Token : 새로운 엑세스 토큰을 생성하는 용도로 사용. 유효 기간을 길게 설정한다.

JWT 구조

헤더, 페이로드, 서명 세 가지 정보를 base64로 인코딩한 값을 콤마(.)를 사이에 두고 이어붙인 형태로 생성된다.

  1. Header : JWT 서명에 사용된 알고리즘을 담는다.
  2. Payload : 서버에서 사용할 수 있는 사용자의 정보(주체, 만료일, 생성자) 등을 담는다. 민감한 정보는 담지 않는다.
  3. Signature : Header에서 지정한 알고리즘을 사용하여 Header와 Payload에 대해서 단방향 암호화를 수행하여 그 값을 담는다.

토큰 기반 인증 절차

  1. 클라이언트가 서버에 아이디/비밀번호를 담아 로그인 요청을 보낸다.

  2. 아이디/비밀번호가 일치하는지 확인하고, 클라이언트에게 보낼 암호화 된 토큰을 생성한다. 이 때, Access Token과 Refresh Token을 모두 생성한다.

  3. 토큰을 클라이언트에게 전송하면, 클라이언트는 토큰을 저장한다. 저장하는 위치는 Local Storage, Session Storage, Cookie 등이 될 수 있다.

  4. 클라이언트가 HTTP Header(Authorization Header) 또는 쿠키에 토큰을 담아 request를 전송한다.

  5. 서버는 토큰을 검증하여 발급한 토큰이 맞을 경우, 클라이언트의 요청을 처리한 후 응답을 보내준다.

JWT 생성 및 검증 테스트

의존 라이브러리

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

JWT 생성

mport io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.Map;

public class JwtTokenizer {
    // Plain Text 형태인 Secret Key의 byte[]를 Base64 형식의 문자열로 인코딩
    public String encodeBase64SecretKey(String secretKey) {
        return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    //  인증된 사용자에게 JWT를 최초로 발급해주기 위한 JWT 생성
    public String generateAccessToken(Map<String, Object> claims,
                                      String subject,
                                      Date expiration,
                                      String base64EncodedSecretKey) {
        //Base64 형식 Secret Key 문자열을 이용해 Key 객체를 얻음
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey); 

        return Jwts.builder()
                .setClaims(claims)          // 인증된 사용자의 정보
                .setSubject(subject)        // JWT 제목
                .setIssuedAt(Calendar.getInstance().getTime())   // 발행일자
                .setExpiration(expiration)  // 만료일시
                .signWith(key)              // 서명을 위한 Key 객체 생성
                .compact();                 // JWT 생성 후 직렬화
    }

    // Refresh 토큰 생성
    public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        return Jwts.builder()
                .setSubject(subject)
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(expiration)
                .signWith(key)
                .compact();
    }
    

    //  JWT의 서명에 사용할 Secret Key를 생성
    private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);  
        Key key = Keys.hmacShaKeyFor(keyBytes); 

        return key;
    }
}

JWT 생성 테스트

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.util.*;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;

@TestInstance(TestInstance.Lifecycle.PER_CLASS) 
public class JwtTokenizerTest {
    private static JwtTokenizer jwtTokenizer;
    private String secretKey;
    private String base64EncodedSecretKey;

    // 테스트에 사용할 Secret Key를 Base64 형식으로 인코딩 한 후, 인코딩 된 Secret Key를 각 테스트 케이스에서 사용
    @BeforeAll
    public void init() {
        jwtTokenizer = new JwtTokenizer();
        secretKey = "kevin1234123412341234123412341234";  // encoded "a2V2aW4xMjM0MTIzNDEyMzQxMjM0MTIzNDEyMzQxMjM0"

        base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(secretKey);
    }

    // Secret Key가 Base64 형식으로 인코딩이 정상적으로 수행이 되는지 테스트
    @Test
    public void encodeBase64SecretKeyTest() {
        System.out.println(base64EncodedSecretKey);

        assertThat(secretKey, is(new String(Decoders.BASE64.decode(base64EncodedSecretKey))));
    }

    // JwtTokenizer가 Access Token을 정상적으로 생성하는지 테스트
    @Test
    public void generateAccessTokenTest() {
        Map<String, Object> claims = new HashMap<>();
        claims.put("memberId", 1);
        claims.put("roles", List.of("USER"));

        String subject = "test access token";
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.MINUTE, 10);
        Date expiration = calendar.getTime();

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        System.out.println(accessToken);

        assertThat(accessToken, notNullValue());
    }

    // Refresh Token을 정상적으로 생성하는지 테스트
    @Test
    public void generateRefreshTokenTest() {
        String subject = "test refresh token";
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.HOUR, 24);
        Date expiration = calendar.getTime();

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        System.out.println(refreshToken);

        assertThat(refreshToken, notNullValue());
    }
}

JWT 검증

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;

public class JwtTokenizer {
    public void verifySignature(String jws, String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        Jwts.parserBuilder()
                .setSigningKey(key)     // 메서드로 서명에 사용된 Secret Key를 설정
                .build()
                .parseClaimsJws(jws);   // 메서드로 JWT를 파싱해서 Claims를 얻음
    }

}

JWT 검증 테스트

import io.jsonwebtoken.ExpiredJwtException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.util.*;
import java.util.concurrent.TimeUnit;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class JwtTokenizerTest {
    private static JwtTokenizer jwtTokenizer;
    private String secretKey;
    private String base64EncodedSecretKey;


    // Signature 검증 테스트
    @DisplayName("does not throw any Exception when jws verify")
    @Test
    public void verifySignatureTest() {
        String accessToken = getAccessToken(Calendar.MINUTE, 10);
        assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
    }

    // JWT 생성시 지정한 만료일시가 지나면 JWT가 정말 만료되는지를 테스트
    @DisplayName("throw ExpiredJwtException when jws verify")
    @Test
    public void verifyExpirationTest() throws InterruptedException {
        String accessToken = getAccessToken(Calendar.SECOND, 1);
        assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));

        TimeUnit.MILLISECONDS.sleep(1500);

        assertThrows(ExpiredJwtException.class, () -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
    }
    
    ...
    ...

    private String getAccessToken(int timeUnit, int timeAmount) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("memberId", 1);
        claims.put("roles", List.of("USER"));

        String subject = "test access token";
        Calendar calendar = Calendar.getInstance();
        calendar.add(timeUnit, timeAmount);
        Date expiration = calendar.getTime();
        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }
}
profile
BE 개발자를 꿈꾸는 대학생

0개의 댓글