JWT(JSON Web Token)
- 데이터를 안전/간결하게 전송하기 위해 고안된 인터넷 표준 인증 방식
- 유저를 인증하고 식별하기 위한 토큰 기반 인증 방식
- JSON 형태의 토큰 정보를 인코딩 후, 인코딩된 토큰 정보를 Secret Key로 Sign한 메시지를 Web Token으로 만들어 인증과정에 사용
JWT의 종류
1. Access Token
- 보호된 정보들에 접근할 수 있는 권한을 부여할 때 사용
➡️로그인 시 Access/Refresh Token 두 가지 다 받지만, 실제로 권한을 얻을 때 사용되는 토큰은 Access Token
2. Refresh Token
- 새로운 Access Token을 발급할 때 사용
➡️일반적으로 DB에 유저 정보와 함께 기록
JWT 구조
Header
- 토큰의 종류와 어떤 알고리즘으로 서명할지 정의되어 있다.
- 정의된 JSON 객체를 base64 방식으로 인코딩 하면 Header부분이 완성된다.
Payload
- 서버에서 활용할 수 있는 사용자의 정보(접근권한, 이름 등 >> Claim)가 담겨있다.
Signature
- 원하는 secret key와 header에서 지정한 알고리즘으로 Header와 Payload에 대해 단방향 암호화를 수행한다.
토큰 기반 자격 증명
세션을 사용할 때 존재하는 서버측의 부담을 줄이기 위해 토큰을 기반한 인증을 사용하는데, 대표적인 토큰기반 인증방식이 JWT를 사용하는 것이다.
☑️세션 기반 자격 증명 방식
- 인증된 사용자의 정보를 Session 형태로 세션 저장소(서버)에 저장하는 방식
- 클라이언트가 서버에게 resouce 요청 시, 서버 측에서는 resource를 제공해도 되는지 확인하기 위해 세션 저장소에 저장된 세션 정보와 사용자가 제공하는 정보가 일치하는지 확인하는 방식
특징➡️
- 인증된 사용자 정보를 서버 측 세션 저장소에 저장
- 생성된 사용자의 고유 session-id는 클라이언트의 쿠키에 저장되어 클라이언트가 request를 전송할 때 인증된 사용자인지 증명할 때 사용
- 클라이언트 쪽에서는 Session ID만 사용하면 되기 때문에 상대적으로 적은 네트워크 트래픽 사용
- 서버 측에서 세션 정보를 관리하기 때문에 상대적으로 보안성 강함
- 세션 불일치 문제 발생 가능성 있음
- 세션 데이터 많아지면 서버의 부담 증가
- SSR(Server Side Rendering) 방식의 애플리케이션에 적합한 방식
☑️토큰 기반 자격 증명 방식
- 인증된 사용자의 자격을 증명하는 동시에 접근권한을 부여해, 접근권한이 부여된 특정 리소스에만 접근이 가능하게 하는 '토큰'을 사용하는 방식
특징➡️
- 인증된 사용자 정보를 별도로 관리하지 않음
- 생성된 토큰을 request Header에 포함시켜 인증된 사용자인지 증명
- 토큰 내에 인증된 사용자 정보 등이 포함되어 있어 세션보다 상대적으로 많은 네트워크 트래픽 사용
- 서버가 토큰을 관리하지 않기 때문에 상대적으로 보안성 약함
- 인증된 사용자의 request 정보를 유지 하지 않아도 되기 때문에 서버 확장성 면에서 유리
- 세션 불일치 문제 발생 x
- 토큰이 탈취되면 정보를 쉽게 확인할 수 있기 때문 민감한 정보를 포함시키지 않아야 함
- 토큰이 만료되기 전까지는 토큰 무효화 불가능
- CSR(Client Side Rendering) 방식의 애플리케이션에 적합한 방
JWT를 통한 인증의 장단점
Good!
1. Stateless(무상태성인), Scalable(확장가능한)의 애플리케이션을 구현하기에 용이하다.
🗨️서버는 클라이언트에 대한 정보를 저장할 필요 없다. 토큰이 정상적으로 검증되는지만 판단하면 된다.
🗨️클라이언트는 request를 전송할 때마다 토큰을 헤더에 포함시키면 된다.
🗨️여러 대의 서버를 이용한 서비스라면 하나의 토큰으로 여러 서버에 인증이 가능해서 JWT의 사용이 효과적!
2. 클라이언트가 request를 전송할 때마다 자격증명 정보를 전송하지 않아도 된다.
🗨️HTTP Basic 같은 인증방식은 request를 전송할 때마다 자격증명 정보를 포함해야 한다..!
🗨️JWT는 토큰 만료 전까지는 한 번만 인증하면 된다.
3. 인증을 담당하는 시스템을 다른 플랫폼으로 분리하는 것이 용이하다.
🗨️사용자의 자격 증명 정보를 직접 관리하지 않고 Google 등을 이용해 다른 플랫폼의 자격으로 인증할 수 있다.
🗨️토큰 생성용 서버를 따로 만들거나 다른 회사에서 토큰 관련 작업을 맡아줄 수 있다.
4. 인가에 유리하다.
🗨️토큰의 payload 안에 해당 사용자의 권한 정보를 포함시키면 된다.
Bad!
1. Payload의 Decoding 용이
🗨️Payload는 base64로 인코딩되기 때문에 토큰이 탈취됐을 때-
payload에서 토큰 생성시 저장했던 데이터를 확인할 수 있다.
➡️Payload에는 민감한 정보를 넣지 않는 것이 바람직하다.
2. 토큰 길이에 따른 네트워크 부하
🗨️토큰에 저장되는 정보의 양이 많아지면 토큰 길이가 길어지게 되고,
request를 전송할 때마다 긴 토큰이 전송되면 네트워크에 부하가 생길 수 있다.
3. 토큰의 유지
🗨️한 번 생성된 토큰은 자동으로 삭제되지 않기 때문에 토큰 만료기간을 반드시 정해줘야 한다.
🗨️토큰이 탈취되면 만료되기 전까지 토큰 탈취범(!)이 해당 토큰을 용할 수 있기 때문에 만료시간을 짧게 정하자.
토큰기반 인증 절차
1. 클라이언트가 username/passord로 로그인을 시도한다.
2. 서버에서는 username/password를 검증 후, 클라이언트에게 보낼 암호화된 Token을 생성한다.
🌟Access Token & Refresh Token 모두 생성
🌟토큰에 담길 정보(payload)는 사용자 식별 정보, 사용자 권한 정보 등이 있다.
🌟Refresh Token으로 Access Token을 생성하기 때문에 두 토큰에 똑같은 정보가 담겨있을 필요는 없다.
3. Token을 클라이언트로 전송하면 클라이언트는 Token을 저장(Local, Session, Cookie 등 에다가)한다.
4. 클라이언트는 HTTP Header(Authorization Header)또는 쿠키에 Token을 담아서 request를 전송한다.
5. 서버는 Token 검증 후 성공할 경우, 클라이언트의 요청을 처리한 후 response를 보낸다.
JWT 생성
✔️ 의존 라이브러리 추가
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
...
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
✔️ 기능 구현
public class JwtTokenizer {
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
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();
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
}
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
1. Plain Text 형태의 secret key의 byte[]를 get해서 Base64 형식의 문자열로 인코딩한다.
public String generateAccessToken(...) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims) //JWT에 포함시킬 Custom Claims 추가
.setSubject(subject) //JWT 제목 추가
.setIssuedAt(Calendar.getInstance().getTime()) //JWT 발행일자 추가
.setExpiration(expiration) //JWT 만료일자 추가
.signWith(key) //서명을 위한 Key객체 설정
.compact(); //JWT 객체 생성 & 직렬화
}
...
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
...
return key;
}
2. 인증된 사용자에게 JWT를 최초로 발급해주기 위한 JWT 생성 메서드이다.
- Base64 형식의 secret key 문자열을 이용해 java.security.Key 객체를 얻는다.
- Decoders.BASE64.decode() 메소드는 Base64 형식으로 인코딩된 secret key를 디코딩하고 byte array를 반환한다.
- Keys.hmacShaKeyFor() 메소드는 key byte array를 기반으로 HMAC 알고리즘을 적용한 Key를 생성한다.
(jjwt 0.11.5 부터는 내부적으로 적절한 HMAC 알고리즘을 알아서 지정해 준다.)
public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey){...}
3. Refresh Token을 생성한다.
(Access Token을 새로 발급해주는 역할이기 때문에 Custom Claims는 따로 필요 없음)
JWT 검증
❗아래의 기능은 인증된 사용자가 애플리케이션의 resource에 접근할 때 마다 request의 header에 포함된 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)
.build()
.parseClaimsJws(jws);
}
...
}
JWT 생성을 위한 JwtTokenizer 클래스에 검증을 위한 verifySignature() 메소드를 추가한다.
- JWT에 포함된 Signature를 검증해서 JWT의 위조/변조 여부를 확인할 수 있다.
- jjwt에서는 JWT의 signature에 사용된 secret key를 이용해서 내부적으로 signature를 검증한 후, 검증에 성공하면 JWT를 파싱해서 Claims를 얻어낸다.
- setSigningKey() 메소드를 통해 signature에서 사용된 secret key를 설정한다.
- parseClaimsJws() 메소드로 JWT를 파싱해 Claims를 얻어낸다.
- verifySignature() 메소드는 signature를 검증만 하는 메소드이기 때문에 리턴값은 따로 존재하지 않는다.
- jws => Json Web Signature(Signature가 포함된 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;
@Test
public void verifySignatureTest() {
String accessToken = getAccessToken(Calendar.MINUTE, 10);
assertDoesNotThrow(
() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey)
);
}
@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;
}
}
public void verifySignatureTest() {...}
1. verifySignature() 메소드를 테스트한다.
생성된 JWT를 verifySignature() 메소드에 전달했을 때 Exception이 발생하지 않는다면 signature에 대한 검증이 성공적이라는 것을 알 수 있다.
public void verifyExpirationTest() throws InterruptedException {...}
2. JWT 생성 시 지정한 만료일시가 지났을 때 토큰이 만료되었는지 테스트한다.
만료일시를 짧게 설정 후 verifySignature() 메소드로 signature 검증 후 만료일이 지나도록 sleep() 메소드 실행 후에 Exception이 발생하면 JWT가 정상적으로 만료되었다는 것을 알 수 있다.
'🌿With Spring > Spring Security' 카테고리의 다른 글
Servlet Filter와 Filter Chain (0) | 2022.11.28 |
---|---|
OAuth2 알아보기 (0) | 2022.11.25 |
Spring Security의 인가 처리 과정 (0) | 2022.11.22 |
Spring Security 인증 처리 과정 (0) | 2022.11.21 |
Spring Security의 요청 처리 과정 (0) | 2022.11.21 |