Spring Security - JWT 인증

jungseo·2023년 7월 13일
0

Spring

목록 보기
23/23

세션 기반 자격 증명

  • 서버 측에 인증된 사용자의 정보를 세션 형태로 세션 저장소에 저장하는 방식
  • 클라이언트 측에서 서버 측의 리소스를 요청하면 서버 측에서 세션 저장소에 저장된 세션 정보와 사용자가 제공하는 정보가 일치하는지 확인

특징

  • 인증된 사용자 정보를 서버 측 세션 저장소에서 관리
  • 생성된 사용자 세션의 고유 ID는 클라이언트 쿠키에 저장되며 request 전송 시 인증된 사용자인지 증명
  • 서버 측에서 세션 정보를 관리해 보안성이 조금 더 높음
  • 서버 확장시 세션 불일치 문제 발생
  • 세션 데이터가 많아질 수록 서버에 부담 가중
  • SSR 방식에 적합

토큰 기반 자격 증명

  • 토큰 : 사용자임을 증명하는 자격 증명 정보

특징

  • 토큰의 사용자 정보를 서버측에서 관리하지 않음
  • 토큰을 헤더에 포함해 request 전송 시 인증된 사용자임을 증명
  • 토큰 내 사용자 정보 등을 포함해 세션에 비해 상대적으로 많은 네트워크 트래픽 사용
  • 서버 측에서 토큰을 관리하지 않아 보안성 측면에서 조금 더 불리
  • 인증된 사용자의 request를 유지할 필요가 없어 서버 확장성 측면에서 유리, 세션 불일치 문제 발생X
  • 토큰에 포함된 사용자 정보는 암호화되지 않아 민감 정보는 토큰에 포함하지 않아야 함
  • 토큰이 만료되기까지 무효화 불가능
  • CSR 방식에 적합

JWT (JSON Web Token)

  • JSON 포맷의 토큰 정보를 인코딩 후, 인코딩 된 토큰 정보를 Secret Key로 서명(Sign)한 메시지를 Web Token으로써 인증 과정에 사용

1. JWT 종류

  • Access Token
    • 보호된 정보에 접근할 수 있는 권한 부여에 사용
    • 클라이언트가 처음 인증 시 Access Token과 Refresh Token을 모두 받지만 실제로 권한을 얻을때 액세스 토큰 사용
    • 탈취되더라도 오랫동안 사용할 수 없도록 짧은 유효기간 부여를 권장
  • Refresh Token
    • Access Token 만료시 Refresh Token을 사용해 새로운 Access Token 발급
    • 편의보다 정보를 지키는 것이 더 중요한 웹 애플리케이션의 경우 Refresh Token을 사용하지 않는 경우 다수

2. 구조

https://jwt.io/

  • JSON 객체를 base64 방식으로 인코딩

1) Header

  • 어떤 종류의 토큰인지
  • 어떤 알고리즘으로 Sign 할지
{
  "alg": "HS256",
  "typ": "JWT"
}

2) Payload

  • 서버에서 활용할 수 있는 사용자의 정보
  • 권한, 사용자 이름 등
  • Signature로 유효성이 검증될 정보
  • 민감 정보 지양
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

3) Signature

  • SecretKey와 Header에서 지정한 알고리즘을 사용해 Header와 Payload에 대해 단방향 암호화 수행
// HMAC SHA256 알고리즘 사용 시

HMACSHA256(
  base64UrlEncode(header) + 
  "." + 
  base64UrlEncode(payload),
  your-256-bit-secret
)

3. 인증 절차

  • 클라이언트가 서버에 로그인 요청
  • 아이디/비밀번호가 일치하는지 확인 후 클라이언트에게 보낼 암호화된 토큰 생성
    • Access Token, Refresh Token 모두 생성
  • 토큰을 클라이언트에게 전송하면 클라이언트는 토큰을 저장
    • Local Storage, Session Storage, Cookie 등에 저장
  • 클라이언트가 HTTP Header 또는 쿠키에 토큰을 담아 Reqeust 전송
  • 서버는 토큰 검증 후 클라이언트 요청 처리

4. 특징

장점

  • 상태를 유지하지 않고(Stateless) 확장에 용이한(Scalable) 애플리케이션 구현에 용이
    • 서버는 클라이언트의 정보를 저장할 필요 없이 토큰만 검증
    • 여러 서버를 이용한 서비스일 경우 하나의 토큰으로 여러 서버에서 인증 가능
      세션 방식의 경우 모든 서버가 해당 사용자의 세션 정보를 공유하여야 함
  • 클라이언트가 request 전송 마다 자격증명정보를 전송할 필요 없이 토큰이 만료되기까지 한 번의 인증만 수행
  • 인증 시스템을 다른 플랫폼으로 분리 용이
    • 사용자 자격 증명 정보를 직접 관리하지 않고 다른 플랫폼의 자격 증명 정보로 인증 가능
  • Payload안에 사용자 권한 정보를 포함시켜 권한 부여에 용이

단점

  • Payload 디코딩이 용이
    • 토큰 탈취 시 디코딩하여 저장 데이터 확인 가능
  • 토큰의 저장된 정보 양이 많아질 수록 네트워크에 부하
  • 토큰이 자동으로 삭제 되지 않음
    • 토큰 만료 시간을 반드시 추가

5. JWT 생성

1) 의존성 추가

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

2) JWT 생성 및 검증 기능 구현

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 {
    //    (1) Secret Key의 byte[]를 Base64 형식의 문자열로 인코딩
    public String encodeBase64SecretKey(String secretKey) {
        return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    //    (2) 인증된 사용자에게 JWT를 최초로 발급
    public String generateAccessToken(Map<String, Object> claims,
                                      String subject,
                                      Date expiration,
                                      String base64EncodedSecretKey) {

//        (2-1) Base64 형식 Secret Key 문자열을 이용해 Key 객체를 얻음
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        return Jwts.builder()
                .setClaims(claims)                             // (2-2) Custom Claims에는 주로 인증된 사용자와 관련된 정보를 추가
                .setSubject(subject)                           // (2-3) JWT에 대한 제목을 추가
                .setIssuedAt(Calendar.getInstance().getTime()) // (2-4) JWT 발행 일자를 설정 (java.util.Date)
                .setExpiration(expiration)                     // (2-5) JWT의 만료일시를 지정 (java.util.Date)
                .signWith(key)                                 // (2-6) 서명을 위한 Key 객체를 설정
                .compact();                                    // (2-7) JWT를 생성하고 직렬화
    }

    //    (3) Access Token이 만료되었을 경우, Access Token을 새로 생성할 수 있게 해주는 Refresh Token을 생성
    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();
    }

//    (4) JWT의 서명에 사용할 Secret Key를 생성
    private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {

//        (4-1) Base64 형식으로 인코딩 된 Secret Key를 디코딩한 후, byte array를 반환
        byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);

//        (4-2) key byte array를 기반으로 적절한 HMAC 알고리즘을 적용한 Key 객체를 생성
        Key key = Keys.hmacShaKeyFor(keyBytes);

        return key;
    }

//    (5) JWT 검증 Signature를 검증해 위/변조 확인
    public void verifySignature(String jws, String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        Jwts.parserBuilder()
                .setSigningKey(key)   // (5-1) 메서드로 서명에 사용된 Secret Key를 설정
                .build()
                .parseClaimsJws(jws); // (5-2) JWT를 파싱해서 Claims를 얻음 / jws 는 signature가 포함된 JWT
    }
}

인코딩(Encoding) :
어떠한 정보/데이터의 형식을 데이터 표준화, 보안 등을 위해 다른 형태, 형식으로 변환하는 것

base64 :
문자를 8비트로 끊어진 2진법에서 6비트씩 끊어 새로운 문자를 형성, 남는 비트엔 0, 부족한 비트엔 = 으로 패딩

Key 인터페이스

  • 모든 키들의 최상위 인터페이스
  • Key Algorithm, Encoded Form, Encoded key의 형식의 이름 포함

Keys 클래스

  • SecretKeys와 KeyPair를 생성하는 유틸리티 클래스
  • hmacShaKeyFor()
    • HMAC-SHA 알고리즘으로 SecretKey 인스턴스를 byte[] 형태로 생성
    • 키 사이즈가 256 비트(32 바이트) 보다 작을 때 WeakKeyException 예외 발생

6. 검증 테스트

import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.io.Decoders;
import org.junit.jupiter.api.BeforeAll;
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.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

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

//    (1)
    @BeforeAll
    public void init() {
        jwtTokenizer = new JwtTokenizer();
        secretKey = "jungseo99881234998812349988123499881234";

        base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(secretKey);
    }

//    (2)
    @Test
    public void encodeBase64SecretKeyTest() {
        System.out.println(base64EncodedSecretKey);

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

//    (3)
@Test
public void generateAccessTokenTest() {
    String accessToken = getAccessToken(Calendar.MINUTE, 10);

    assertThat(accessToken).isNotNull();
    }

//    (4)
    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).isNotNull();
    }

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

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

0개의 댓글