TIL 6 | RSA 암호화를 이용한 JWT 토큰 생성하기 (1)

dereck·2024년 11월 26일

TIL

목록 보기
6/21
post-thumbnail

암호화 방식의 차이

JWT Header에는 토큰의 알고리즘과 타입이 들어간다.

{
  "typ": "JWT",
  "alg": "RS256"
}

여기서 알아볼 것은 바로 알고리즘에 따른 암호화 방식의 차이이다.

HMAC (Hash based Message Authentication Code)

HMAC 방식은 MAC 기술의 일종으로 원본 메시지가 변하면 그 해시값도 변하는 해싱(Hashing)의 특징을 활용하여 메시지의 변조 여부를 확인하여 무결성과 기밀성을 제공하는 기술이다.

  1. 송신자와 수신자가 해싱에 사용할 키를 공유한다.
    • 이때 양쪽이 같은 키를 사용하기 때문에 대칭키 방식이라고도 한다.
  2. 송신자는 키를 사용하여 원본 메시지를 해싱한다.
    • 여기서 해싱된 메시지가 MAC이다.
  3. 송신자는 원본 메시지와 해싱된 메시지(MAC)을 수신자에게 전달한다.
  4. 수신자는 키를 사용하여 원본 메시지를 해싱하고, 송신자에게 받은 해싱된 메시지(MAC)와 비교한다.
  5. 두 값이 동일하다면 원본 메시지는 변조되지 않았고, 신뢰할 수 있는 값이라고 판단한다.
    • 만약 누군가가 메시지를 변조했다면, 수신자의 MAC와 송신자의 MAC 값이 다르기 때문에 변조된 것으로 판단.

해당 기술은 실제로 사용할 땐 HTTPS와 같은 보안 채널을 통해 원본 메시지를 보호하고, MAC과 같이 전달하는 것이 일반적이다.

해싱은 암호화가 아닌, 무결성을 확인하는 기술

RSA (Rivest-Shamir-Adleman)

RSA 방식은 공개키 암호 시스템 중 하나로 암호화 뿐만 아니라 전자서명이 가능한 최초의 알고리즘이다. 전자 상거래에서 가장 흔히 쓰이는 공개키 알고리즘이며 큰 정수의 소인수 분해가 어렵다는 점을 이용하여 암호화한 것이다.

RSA는 비대칭 키를 사용하는 양방향 알고리즘에 속한다. 비대칭 암호화 방식에는 공개키와 비밀키의 2가지 키가 필요하다.

B가 A에게 메시지를 전하고자 할 때

  1. B는 A에게 공개키 요청을 한다.
  2. A는 B에게 공개키를 전달한다.
  3. B는 받은 공개키로 메시지를 암호화하고, A에게 암호화된 메시지를 전달한다.
  4. A는 받은 메시지를 개인키를 통해 복호화한다.

여기서 A가 B의 공개키로 문서를 열 때 열린다면 A가 보낸 것이 인증된 것이다. 그리고 A가 개인키로 B가 보낸 메시지를 열었을 때 열린다면 암호화가 잘된 것이다.

Spring Boot를 활용하여 JWT 토큰 발급하기

RSA 키 생성

암호화 방식에 대해 알아보았으니 이제 본격적으로 RSA를 사용한 개인키를 만들어보자. 생성하는데 여러 방법이 있지만 나는 openssl을 통해서 생성했다.

openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048

JWT 토큰의 암호화 알고리즘 방식으로 RS256을 사용할 것이기 때문에 최소 2048 비트가 필요하다. 위의 방식으로 개인키를 만들었다면 이제 개인키를 가지고 공개키를 만들어보자.

openssl rsa -pubout -in private_key.pem -out public_key.pem

이렇게 공개키와 개인키를 만들었다.

Spring Boot 프로젝트 생성

spring initializr를 통해 Spring Boot 프로젝트를 만들어준다. 만약 본인이 JetBrain의 인텔리제이 엔터프라이즈를 사용하고 있다면 인텔리제이를 통해 만들어도 된다.

dependencies {
    // spring data jpa
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // spring web
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // spring security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.security:spring-security-test'

    // lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // mysql
    runtimeOnly 'com.mysql:mysql-connector-j'

    // spring-validation
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // jwt dependencies
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

    // Nimbus JOSE
    implementation 'com.nimbusds:nimbus-jose-jwt:9.31'
}

properties 또는 yml 파일에 공개키와 개인키 위치 추가

흔히 말하는 설정 파일인 properties 또는 yml 파일에 아까 만들어 놓은 공개키와 개인키의 위치를 지정해준다. 실제 프로젝트에선 개인키를 public 하게 올려놓으면 절대 안된다.

jwt:
  private_key_path: "your_private_key_path"
  public_key_path: "your_public_key_path"

이렇게 해놓으면 이후 @Value로 설정 파일에서 값을 읽어올 수 있다.

KeyLoader

이제 저장된 위치에 있는 공개키와 개인키를 읽어오는 코드를 작성해보자.

public class KeyLoader {

    public static PrivateKey loadPrivateKey(String filePath) throws 
    							IOException, NoSuchAlgorithmException, InvalidKeySpecException {
        String key = new String(Files.readAllBytes(new File(filePath).toPath()));
        key = key.replace("-----BEGIN PRIVATE KEY-----", "")
            .replace("-----END PRIVATE KEY-----", "")
            .replace("\\s", "")
            .replace("\r", "")
            .replace("\n", "");

        byte[] keyBytes = Base64.getDecoder().decode(key);
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        return keyFactory.generatePrivate(keySpec);
    }

    public static RSAPublicKey loadPublicKey(String filePath) throws 
    							IOException, NoSuchAlgorithmException, InvalidKeySpecException {
        String key = new String(Files.readAllBytes(new File(filePath).toPath()));
        key = key.replace("-----BEGIN PUBLIC KEY-----", "")
            .replace("-----END PUBLIC KEY-----", "")
            .replace("\\s", "")
            .replace("\r", "")
            .replace("\n", "");

        byte[] keyBytes = Base64.getDecoder().decode(key);
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        return (RSAPublicKey) keyFactory.generatePublic(keySpec);
    }
}

매개변수로 파일의 위치를 받아서 Files.readAllBytes()byte[]로 만들어진 값을 String으로 변환하여 변수에 저장한다.

key의 내용을 보면 위, 아래에 각각 begin, end를 나타내고 있고, 사이에 이스케이프 문자가 있다. 이것을 제거하기 위해 replace()로 제거를 해줬다.

제거가 완료된 값을 Base64를 통해 decode()byte[]로 만들고, 공개키는 X509EncodedKeySpec, 개인키는 PKCS8EncodedkeySpec으로 만들었다.

KeyFactory에서 getInstance()를 통해 알고리즘을 가져오고, 각각 RSAPublicKeyPrivateKey 타입으로 return한다.

KeyConfig

KeyLoader를 통해 생성한 RSAPublicKeyPrivateKey@Bean으로 만들기 위해서 다음과 같이 만들어줬다.

@Configuration
public class KeyConfig {

    @Value("${jwt.private_key_path}")
    private String privateKeyPath;

    @Value("${jwt.public_key_path}")
    private String publicKeyPath;

    @Bean
    public PrivateKey privateKey() throws 
    							IOException, NoSuchAlgorithmException, InvalidKeySpecException {
        return KeyLoader.loadPrivateKey(privateKeyPath);
    }

    @Bean
    public RSAPublicKey publicKey() throws 
    							IOException, NoSuchAlgorithmException, InvalidKeySpecException {
        return KeyLoader.loadPublicKey(publicKeyPath);
    }
}

여기서 각각의 keyPath는 설정 파일에 정의해 둔 값을 가져와서 사용했다.

JwtManager

실질적으로 JWT를 생성하는 클래스이다. 전자서명을 위해 개인키를 필드에 선언 후 생성자를 통해 초기화했다.

@Component
public class JwtManager {

    private final PrivateKey privateKey;

    public JwtManager(PrivateKey privateKey) {
        this.privateKey = privateKey;
    }

    public String generateToken(String subject, String role) throws JOSEException {
        JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
            .subject(subject)
            .claim("role", role)
            .issuer("dereck-jun")
            .issueTime(new Date())
            .expirationTime(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 3))
            .build();

        SignedJWT signedJWT = new SignedJWT(
            new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JWT).build(), claimsSet);
            
        signedJWT.sign(new RSASSASigner(privateKey));

        return signedJWT.serialize();
    }
}

generateToken()의 매개변수로 Payload에 넣을 subjectrole을 받는다. 받아온 값을 토대로 JWTClaimsSet을 만들고, new SignedJWT()를 하면서 Header에 들어갈 값인 typalg를 지정해준다. 여기서 현재 우리가 만들고자 하는 typJWT이고, 사용할 algRS256이다.

지정된 HeaderClaimSet을 사용하여 서명할 새로운 JWT를 만들었다면 이전에 만들어 둔 privatekey로 전자 서명을 진행한다. 서명까지 마쳤다면 JWS 객체를 마침표(.)로 구분된 Base64URL 인코딩 부분으로 구성된 압축 형식으로 직렬화하여 반환하다.

테스트 진행

테스트를 진행하기 이전에 이번 내용은 JWT에 대한 내용이므로 간단한 엔티티와 컨트롤러, securityFilterChain 등은 미리 만들어 두었다. 그리고 테스트는 인텔리제이 내부의 .http로 진행했다.

회원가입 시 POST http://localhost:8080/api/users 요청을 보내고, 성공적으로 완료됐다면 만들어 놓은 공통 응답 record를 통해 응답을 하도록 만들었다.

# request
POST http://localhost:8080/api/users
Content-Type: application/json

{
  "email": "tester@test.com",
  "password": "tester",
  "name": "myTester"
}

---
# response
{
  "isSuccess": true,
  "data": {
    "name": "myTester",
    "email": "tester@test.com",
    "password": "$2a$10$Qg34043XGQJ3Q/KTKEwwJ.LuZjjPQdk5YviCuMdfrKCMNetceybhK",
    "nickname": "user_f19027f2",
    "roles": [
      {
        "name": "ROLE_USER",
        "privileges": [
          {
            "name": "CONFIG_AUTHORITY"
          }
        ]
      }
    ]
  },
  "error": null
}

응답으로 받은 값을 보니 isSuccess 값이 true로 되어있고, data에 회원가입에 기입한 내용,roles에 역할이 잘 기입된 것을 보니 회원가입이 성공적으로 완료된 것 같다.

이제 POST http://localhost:8080/api/users/login에 이메일과 비밀번호를 통해 로그인을 시도하고, 로그인 성공 시 토큰을 발급하도록 만들었다.

# request
POST http://localhost:8080/api/users/login
Content-Type: application/json

{
  "email": "tester@test.com",
  "password": "tester"
}

---
# response
{
  "isSuccess": true,
  "data": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJkZXJlY2stanVuIiwic3ViIjoidGVzdGVyQHRlc3QuY29tIiwicm9sZSI6IlJPTEVfVVNFUiIsImV4cCI6MTczMjYzMjM3NiwiaWF0IjoxNzMyNjIxNTc2fQ.O-V_-MQknMIg6qBLf0cx68QNjAxBllc-J_8TVR-HwxJW9e5XehVULAKAkPsCdR1JZQSJqa5bL5Jpy5Sj42-LfBjWkl2J3rg5kKko_Lzk9oIfIANh3pJ2LCv9s4Y0satQ3kspl67YMaATEGHCgElr5jRixhwE2LSUQbpjCNGMde-pLo_Z3NLdZjTKzbDTihBIiBtvHRc2gIJBI87VQjN0hMfFUDJAUXF30pCRuJgaVIh5Pujbnj4qYQtvOlsd_iOjP44DL7Y1f0Q7pHjUYnxFWOG5__447UeVKq2Vp6UXJnLpnHNKv5019kB41cTusxPzywCxEFRYPMw-dXE25UDUJA",
  "error": null
}

회원가입과 마찬가지로 요청이 성공하고, 응답으로 data에 토큰을 받아온 것을 볼 수 있다. 이제 응답으로 받은 토큰을 여기에서 확인해보자.

토큰 확인하기

JWT 홈페이지에 들어가서 페이지를 아래로 내리면 디버그를 할 수 있는 곳이 있다.

여기서 아래와 같은 순서로 토큰을 확인해보자.

  1. 상단의 AlgorithmRS256으로 변경
  2. Encoded에 응답으로 받은 토큰을 복사 후 붙여넣기
  3. PAYLOAD 밑에 있는 VERIFY SIGNATURE에 내가 사용한 공개키와 개인키를 각각 복사 후 붙여넣기
  4. 최하단에 Signature Verified 확인

만약 값이 이상하다면 위의 사진처럼 Invalid Signature라고 나올 것이다.

References

0개의 댓글