JWT Header에는 토큰의 알고리즘과 타입이 들어간다.
{
"typ": "JWT",
"alg": "RS256"
}
여기서 알아볼 것은 바로 알고리즘에 따른 암호화 방식의 차이이다.
HMAC 방식은 MAC 기술의 일종으로 원본 메시지가 변하면 그 해시값도 변하는 해싱(Hashing)의 특징을 활용하여 메시지의 변조 여부를 확인하여 무결성과 기밀성을 제공하는 기술이다.
해당 기술은 실제로 사용할 땐 HTTPS와 같은 보안 채널을 통해 원본 메시지를 보호하고, MAC과 같이 전달하는 것이 일반적이다.
해싱은 암호화가 아닌, 무결성을 확인하는 기술
RSA 방식은 공개키 암호 시스템 중 하나로 암호화 뿐만 아니라 전자서명이 가능한 최초의 알고리즘이다. 전자 상거래에서 가장 흔히 쓰이는 공개키 알고리즘이며 큰 정수의 소인수 분해가 어렵다는 점을 이용하여 암호화한 것이다.
RSA는 비대칭 키를 사용하는 양방향 알고리즘에 속한다. 비대칭 암호화 방식에는 공개키와 비밀키의 2가지 키가 필요하다.
B가 A에게 메시지를 전하고자 할 때
- B는 A에게 공개키 요청을 한다.
- A는 B에게 공개키를 전달한다.
- B는 받은 공개키로 메시지를 암호화하고, A에게 암호화된 메시지를 전달한다.
- A는 받은 메시지를 개인키를 통해 복호화한다.
여기서 A가 B의 공개키로 문서를 열 때 열린다면 A가 보낸 것이 인증된 것이다. 그리고 A가 개인키로 B가 보낸 메시지를 열었을 때 열린다면 암호화가 잘된 것이다.
암호화 방식에 대해 알아보았으니 이제 본격적으로 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 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 파일에 아까 만들어 놓은 공개키와 개인키의 위치를 지정해준다. 실제 프로젝트에선 개인키를 public 하게 올려놓으면 절대 안된다.
jwt:
private_key_path: "your_private_key_path"
public_key_path: "your_public_key_path"
이렇게 해놓으면 이후 @Value로 설정 파일에서 값을 읽어올 수 있다.
이제 저장된 위치에 있는 공개키와 개인키를 읽어오는 코드를 작성해보자.
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()를 통해 알고리즘을 가져오고, 각각 RSAPublicKey와 PrivateKey 타입으로 return한다.
KeyLoader를 통해 생성한 RSAPublicKey와 PrivateKey를 @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는 설정 파일에 정의해 둔 값을 가져와서 사용했다.
실질적으로 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에 넣을 subject와 role을 받는다. 받아온 값을 토대로 JWTClaimsSet을 만들고, new SignedJWT()를 하면서 Header에 들어갈 값인 typ과 alg를 지정해준다. 여기서 현재 우리가 만들고자 하는 typ은 JWT이고, 사용할 alg은 RS256이다.
지정된 Header와 ClaimSet을 사용하여 서명할 새로운 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 홈페이지에 들어가서 페이지를 아래로 내리면 디버그를 할 수 있는 곳이 있다.

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

Algorithm을 RS256으로 변경Encoded에 응답으로 받은 토큰을 복사 후 붙여넣기PAYLOAD 밑에 있는 VERIFY SIGNATURE에 내가 사용한 공개키와 개인키를 각각 복사 후 붙여넣기Signature Verified 확인
만약 값이 이상하다면 위의 사진처럼 Invalid Signature라고 나올 것이다.