JWT 란❓
JWT는 "JSON Web Token"의 약자로, 웹 애플리케이션에서 사용자 인증 및 정보 교환을 위해 사용되는 토큰 기반의 인증 방법입니다.
JWT는 JSON 포맷을 사용하여 정보를 안전하게 전송하며, 주로 다음과 같은 세 가지 부분으로 구성됩니다.
- 헤더 (Header)
- 토큰의 타입과 사용된 서명 알고리즘을 나타냅니다.
- 일반적으로 alg(algorithm)와 typ(type) 필드를 포함합니다. 예를 들어, HMAC SHA256을 사용할 때는 {"alg": "HS256", "typ": "JWT"}와 같은 형식입니다.
- 페이로드 (Payload)
- 실제 데이터가 담기는 부분입니다.
- 이 데이터는 "클레임(claims)"이라고 불리며, 사용자에 대한 정보(예: 사용자 ID, 만료 시간 등)를 포함합니다.
- 클레임은 "등록된 클레임(registered claims)", "공유 클레임(public claims)", "비공식 클레임(private claims)"으로 나눌 수 있습니다.
- 서명 (Signature)
- 헤더와 페이로드를 조합한 후, 특정 알고리즘과 비밀 키(Secret Key)를 사용하여 생성된 서명입니다.
- 서명은 토큰의 무결성을 검증하고, 변조되지 않았는지 확인하는 데 사용됩니다.
JWT 토큰 기반 인증란❓
JWT (JSON Web Token) 토큰 기반 인증은 웹 애플리케이션에서 사용자의 인증 정보를 안전하게 전달하고 관리하기 위한 인증 방식입니다.
JWT는 인증 상태를 유지하기 위해 토큰을 사용하며, 서버와 클라이언트 간의 상태를 유지하지 않는 "상태 비저장(stateless)" 인증 방법입니다. 쿠키/세션 방식과 유사하게 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별합니다.
JWT(Json Web Token) : 인증에 필요한 정보들을 암호화시킨 토큰
JWT 토큰 기반 인증의 동작 방식
- 사용자 로그인 요청
- 사용자가 로그인 폼에 사용자 이름과 비밀번호를 입력하고 제출합니다.
- 서버의 인증 확인
- 서버는 데이터베이스에서 사용자 이름과 비밀번호를 확인하여 인증을 수행합니다.
- JWT 생성
- 인증이 성공하면, 서버는 사용자의 정보를 JWT로 암호화하여 생성합니다.
- JWT는 사용자 정보와 함께 서명되어 안전하게 전송됩니다.
- JWT 응답
- 서버는 클라이언트에게 JWT를 포함한 응답을 보냅니다.
- 토큰 저장 및 전송
- 클라이언트는 받은 JWT를 로컬 저장소(예: 브라우저의 localStorage 또는 sessionStorage)에 저장하고, 이후의 요청마다 이 JWT를 HTTP 헤더에 담아 서버에 전송합니다.
- 토큰 검증
- 서버는 클라이언트의 요청에서 JWT를 추출하고, 토큰을 검증하여 유효성을 확인합니다.
- 인증된 응답 처리
- 토큰이 유효한 경우, 서버는 로그인된 사용자에 따른 적절한 응답을 반환합니다.
JWT 사용 이유
서버가 1대인 경우
구성
- Session1: 모든 클라이언트의 로그인 정보를 소유합니다.
문제점
- 단일 서버 구조에서는 서버가 모든 로그인 세션을 직접 관리하고 있습니다.
- 이 구조는 상대적으로 간단하지만, 확장성이 제한적입니다.
- 서버의 부하가 증가하면 성능 문제가 발생할 수 있습니다.
서버가 2대 이상인 경우
구성
- 다중 서버: 대량의 트래픽 처리를 위해 여러 서버가 운영됩니다.
문제점:
- 세션 비일관성: 여러 서버가 존재할 경우, 각 서버는 자신의 세션만을 관리하고 있어, 서버 간에 세션 데이터의 불일치가 발생할 수 있습니다.
- Session1 ➡️ Client1, Client2, Client3의 세션을 관리
- Session2 ➡️ Client4의 세션만을 관리
- Client1의 세션 정보가 Server2와 Server3에는 없을 수 있습니다.
해결 방법
- 1. Sticky Session
- 클라이언트의 요청을 항상 같은 서버로 라우팅 합니다.
- 일반적으로 Sticky Session을 유지하기 위해 Cookie를 사용하거나 클라이언트의 IP tracking 하는 방식이 있습니다.
- 이 방법은 서버 간 세션 공유 문제를 피할 수 있지만, 서버 장애나 로드 밸런싱에 제한이 있을 수 있습니다.
- 2. 세션 저장소
- 중앙 집중식 세션 저장소를 만들어 모든 서버가 동일한 세션 정보를 접근할 수 있도록 합니다.
- Redis, Memcached 등이 이 용도로 사용됩니다.
- 장점
- 서버 독립성: 서버 간에 세션을 공유하므로, 특정 서버에
의존하지 않고, 서버를 추가하거나 제거할 때 유연합니다. - 부하 분산: 스티키 세션 없이 로드 밸런서가 요청을 다양한 서버로 분산시킬 수 있습니다.
- 세션 복구: 서버 장애 시에도 세션 정보가 중앙 저장소에 남아 있으므로 복구가 용이합니다.
- 서버 독립성: 서버 간에 세션을 공유하므로, 특정 서버에
- 단점
- 중앙 집중식: 세션 저장소에 부하가 집중될 수 있으며, 저장소가 장애가 나면 모든 서버의 세션에 영향을 줄 수 있습니다.
- 네트워크 지연: 모든 서버가 중앙 저장소에 접근해야 하므로 네트워크 지연이 있을 수 있습니다
3. JWT 사용
- 로그인 정보를
서버에 저장하지 않고, 클라이언트에 JWT를 저장하여 인증/인가를 수행합니다. - JWT를 사용하는 시스템에서는 모든 서버가 동일한 비밀 키(Secret Key)를 공유해야 합니다.
- 비밀 키 불일치 문제
- 클라이언트가 JWT를 Secret Key: A로 생성하고, 서버 1도 Secret Key: A로 서명합니다. 이 경우, JWT는 정상적으로 작동합니다.
- 그러나, 클라이언트가 JWT를 Secret Key: A로 생성했음에도 불구하고 서버 2가 Secret Key: B로 검증을 시도하면, 서명이 일치하지 않아 "잘못된 Signature" 오류가 발생합니다.
- 비밀 키 불일치 문제
로드 밸런싱❓ 클라이언트로부터 오는 요청을 여러 서버로 분배해주는 것
JWT 토큰 장단점
장점
- 동시 접속자가 많을 때 서버 측 부하 낮출 수 있습니다.
- 서버 측 상태를 관리하지 않으므로 확장성과 성능이 향상됩니다.
- 클라이언트와 서버가 다른 도메인일 때 유용하게 사용됩니다.
단점
- 구현 및 관리 복잡도가 증가합니다.
- JWT안에 정보를 담을 수 있습니다. 그러나 정보를 많이 담게 된다면, JWT의 크기가 커지게 됩니다.
- JWT의 크기가 클수록 네트워크 비용이 증가합니다.
- JWT의 일부분만 만료시키거나 갱신하는 것이 어렵습니다.
- 비밀 키 유출 시 보안 문제가 발생할 수 있습니다.
동시 접속자가 많을 때 JWT를 사용하면 서버 측 부하를 낮출 수 있는 이유는, JWT의 검증 과정에서 데이터베이스나 다른 저장소와의 추가적인 연결 없이 단지 비밀 키만으로 검증을 수행하기 때문입니다.
반면, 세션 저장소를 사용하는 경우에는 세션 정보를 저장소에서 매번 확인해야 하므로, 서버에 추가적인 요청이 발생하고, 이로 인해 서버의 부담이 커질 수 있습니다.
JWT 다루기
JWT를 생성하기 위해서는 다음과 같은 작업이 필요합니다.
- 프로젝트 설정
- JWT 데이터 생성
- JWT Token 생성
- 생성된 JWT를 Cookie에 저장
- Cookie에 들어있던 JWT 토큰을 substring
- JWT검증
- JWT에서 사용자 정보 가져오기
2~6번 작업들을 Uti클래스에서 코드를 작성합니다.
Util 클래스란❓
Util 클래스는 주로 다양한 공통 기능이나 도구 메서드를 포함하는 클래스입니다.
이러한 클래스는 여러 다른 클래스나 모듈에서 공통적으로 필요한 작업을 효율적으로 처리하는 데 도움을 줍니다.
보통 이러한 메서드들은 재사용성이 높고, 특정 객체나 인스턴스와 관련된 것이 아니라 범용적으로 사용될 수 있는 유틸리티 함수들입니다.
즉 , 다른 객체에 의존하지 않고 하나의 모듈로서 동작하는 클래스입니다.
그러면 이번 포스팅에서의 JwtUtil 클래스란 JWT와 관련된 기능들을 가진 클래스라고 할 수 있습니다.
1. 프로젝트 설정
JWT dependency 추가
// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
application.properties에 SecretKey설정
jwt.secret.key= @@@
2. Util 클래스_JWT 데이터 생성
먼저 JWT를 생성할 때 필요한 데이터를 설정해줘야 합니다.
- JWT 관련 상수 설정
- AUTHORIZATION_HEADER
- JWT를 담고 있는 HTTP 헤더의 이름을 정의합니다.
- AUTHORIZATION_KEY
- JWT 내에서 사용자 권한 정보를 담는 키를 정의합니다.
- BEARER_PREFIX
- JWT 토큰을 식별하기 위한 접두사입니다.
- TOKEN_TIME
- JWT의 유효 기간을 정의합니다 (예: 60분).
- AUTHORIZATION_HEADER
- Secret Key
- secretKey
- application.properties 파일에서 Base64로 인코딩 된 Secret Key를 읽어옵니다.
- key
- Base64로 인코딩 된 Secret Key를 디코딩하여 HMAC 키로 변환합니다.
- secretKey
- Signature Algorithm
- signatureAlgorithm
- JWT 서명에 사용할 알고리즘을 정의합니다 (예: HS256).
- signatureAlgorithm
- 초기화 메서드
- Base64로 Decoding
- 인코딩 된 Secret Key를 디코딩하여 바이트 배열로 변환합니다.
- Key 변환
- Keys.hmacShaKeyFor() 메서드를 사용하여 JWT 서명 및 검증에 사용할 Key 객체를 생성합니다.
- @PostConstruct
- 이 과정은 Bean 초기화 후 한 번만 실행되므로, Key 객체를 효율적으로 설정할 수 있습니다.
- Base64로 Decoding
@value 어노테이션
import org.springframework.beans.factory.annotation.Value;
@Value 어노테이션은 Spring의 의존성 주입 기능을 활용하여 설정 파일에 정의된 값을 빈의 필드에 주입합니다.
이 방식은 주로 환경 설정 값이나 비밀번호, API 키 등의 값을 클래스에서 직접 사용하고자 할 때 유용합니다.
이번 포스팅에서는 application.properties에 있는 Secret Key를 가져오기 위해서 @value 어노테이션을 사용해서 가져왔습니다.
@PostConstruct 어노테이션
@PostConstruct 어노테이션은 Spring Bean의 초기화 후에 딱 한 번만 실행되는 메서드를 지정하는 데 사용됩니다.
주로 Bean의 설정이나 초기화 작업을 수행할 때 유용합니다.
즉, 딱 한 번만 받아 오면 되는 값을 사용할 때마다 요청을 새로 호출하는 실수를 방지하기 위해서 사용합니다.
3. Util 클래스_JWT Token 생성
JWT를 생성할 때 Jwts.builder()를 사용하여 필요한 데이터를 설정하고, 생성된 JWT에 "Bearer " 프리픽스를 붙여 반환합니다.
각 메서드를 간단하게 살펴보면 다음과 같습니다.
- 현재 시간 설정
- Date date = new Date();
- JWT 빌더 생성
- Jwts.builder()
- JWT에 데이터 추가
- 사용자 식별자 설정
- . setSubject(username)
- 사용자 권한 추가
- . claim(AUTHORIZATION_KEY, role)
- 사용자 식별자 설정
- 만료 시간 설정
- . setExpiration(new Date(date.getTime() + TOKEN_TIME))
- 발급일 설정
- . setIssuedAt(date)
- 서명 및 암호화
- . signWith(key, signatureAlgorithm)
- JWT 완성
- . compact()
JWT를 생성하는 과정에을 정리하자면 현재 시간을 기준으로 사용자 식별자와 권한 정보를 추가하고, 만료 시간과 발급일을 설정한 후, Secret Key와 암호화 알고리즘을 사용하여 서명하여 최종적으로 JWT를 생성합니다.
4. 생성된 JWT Token을 Cookie에 저장
- JWT 토큰을 URL 인코딩하여 쿠키 값으로 사용할 수 있도록 공백을 %20으로 변환합니다.
- Cookie Value에는
공백이 들어가면 안 되기 때문입니다.
- Cookie Value에는
- JWT 토큰을 쿠키 객체에 저장하며, 쿠키의 이름은 Authorization으로 설정합니다.
- Authorization으로 설정하는 이유는, 서버에서 요청을 받을 때 이 쿠키를 통해 토큰을 구별하고 확인할 수 있도록 하기 위함입니다.
- 쿠키의 경로를 설정하여 해당 경로에서 쿠키가 유효하도록 합니다.
- 즉, 루트 경로(/)부터 시작하여 애플리케이션 내의 모든 서브경로에서도 쿠키가 자동으로 전송되며, 이를 통해 인증 정보가 모든 페이지 요청에서 사용될 수 있도록 합니다.
- 서버의 응답 객체(HttpServletResponse)에 쿠키를 추가하여 클라이언트에게 전송합니다.
- 인코딩 과정에서 발생할 수 있는 예외를 처리하고, 에러 메시지를 로깅합니다.
5. JWT 토큰 substring
Cookie의 이름을 Authorization으로 설정하고, 값 부분에는 URL Encoder를 사용해 인코딩 된 JWT 토큰을 넣습니다.
- JWT 토큰은 항상 "Bearer "로 시작하기 때문에, startWith() 메서드를 사용해 이 접두사가 있는지 확인하고, hasText() 메서드를 사용해 공백이나 null 여부를 체크합니다.
- 그 후, substring() 메서드를 이용해 "Bearer " 부분을 잘라내어 순수 JWT 토큰 값만 추출합니다.
6. JWT 토큰 검증
Jwts.parserBuilder()를 사용하여 JWT를 파싱 하고, setSigningKey(key)로 설정된 비밀 키를 통해 서명을 검증합니다.
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
위 코드로 토큰이 위조가 되지 않았는지 만료가 되지 않았는지 체크합니다.
- 만약 검증에 실패하면, 다양한 예외 상황에 따라 적절한 에러 메시지를 로깅합니다
- SecurityException, MalformedJwtException, SignatureException은
JWT 서명이 유효하지 않을 때 발생합니다. - ExpiredJwtException은 JWT가 만료된 경우 발생합니다.
- UnsupportedJwtException은
지원되지 않는 JWT 토큰 형식일 때 발생합니다. - IllegalArgumentException은 JWT의 클레임이 비어 있을 때 발생합니다.
- SecurityException, MalformedJwtException, SignatureException은
- 모든 예외가 발생하면 false를 반환하여 토큰이 유효하지 않음을 나타냅니다.
7. JWT 토큰에서 사용자 정보 가져오기
- Jwts.parserBuilder()를 사용하여 JWT를 파싱 할 준비를 하고, setSigningKey(key)로 설정된 비밀 키를 통해 서명을 검증합니다.
- build(). parseClaimsJws(token)를 호출하여 JWT를 파싱 하고, 서명을 검증한 후, getBody()를 통해 JWT의 페이로드(Claims)를 반환합니다.
- Body 부분에 Claims라는 데이터들이 들어있는 집합 이 존재합니다. 해당 집합을 반환하여 그 안에 들어있는 사용자 정보를 꺼내오는 의미입니다.
JWT Test
이제 완성된 JWT를 테스트를 해보겠습니다.
생성자 주입(DI)
JWT도 @Component로 지정을 해서 Bean으로 등록을 해놨습니다. 그러면 테스트할 Controller에 Bean을 가져와야 합니다.
JwtUtil을 생성자 주입(DI)으로 주입받아 사용합니다. @RequiredArgsConstructor 어노테이션을 통해 자동으로 생성자를 생성할 수도 있습니다.
addCookie() 메서드
public static void addCookie(String cookieValue, HttpServletResponse res) {
try {
// Cookie Value 에는 공백이 불가능해서 encoding 진행
cookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\+", "%20");
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, cookieValue); // Name-Value
cookie.setPath("/");
cookie.setMaxAge(30 * 60);
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e.getMessage());
}
}
JWT 생성 및 쿠키 저장 (/create-jwt 엔드포인트)
JWT를 쿠키에 저장을 할 때 앞서 "쿠키-세션"포스팅에서 알아보았던 addCookie() 메서드를 사용해도 좋지만 새로 JwtUtil에서 만든 addJwtToCookie() 메서드를 사용해 보겠습니다.
JWT 생성 및 쿠키 저장 (/create-jwt 엔드포인트)
- createJwt() 메서드는 JWT를 생성합니다. 이때, 사용자 이름("ZINU")과 권한(USER)을 기반으로 토큰을 생성합니다.
- 생성된 토큰을 addJwtToCookie() 메서드를 통해 쿠키에 저장하고, 이 쿠키를 HttpServletResponse 객체에 추가하여 브라우저에 전송합니다.
- 최종적으로 생성된 JWT를 클라이언트에 반환합니다.
JWT 검증 및 사용자 정보 추출 (/get-jwt 엔드포인트)
- getJwt() 메서드는 쿠키에서 JWT를 추출합니다.
- 추출한 토큰에서 "Bearer " 접두사를 제거하여 순수한 Token을 받아주고, validateToken() 메서드를 사용하여 토큰의 유효성을 검증합니다.
- 유효한 토큰이면 getUserInfoFromToken() 메서드를 통해 사용자 정보를 추출합니다.
- 추출한 정보에서 Claims(사용자 이름("ZINU")과 권한(USER))을 로그로 출력하고, 클라이언트에 이 정보를 반환합니다.
동작 확인
create-jwt
localhost:8080/api/create-jwt
위 url로 접속하여 JWT 토큰을 생성합니다.
브라우저에 생성한 "Bearer" 접두사가 잘 붙은 JWT Token이 잘 보이고, 개발자 도구의 Cookie를 보면 정상적으로 저장이 된 것을 확인할 수 있습니다.
생성한 Cookie를 jwt.io로 들어가서 확인을 해보면, 설정한 알고리즘 "HS256"이 잘 나오고 있고, PAYLOAD를 보면 사용자 이름, 권한, 만료시간, 발급시간 이 잘 나오는 것을 확인할 수 있습니다.
get-jwt
localhost:8080/api/get-jwt
위 url로 접속하여 생성한 JWT 토큰을 가져옵니다.
토큰 검증 및 사용자 정보 추출
- /get-jwt 엔드포인트를 통해 쿠키에서 JWT를 추출하고, 토큰을 검증한 후 사용자 정보를 가져옵니다.
- 브라우저에서 사용자 이름(ZINU)과 권한(USER)이 올바르게 출력되고 있는 것을 확인할 수 있습니다.
JWT 토큰을 jwt.io에 입력하여 생성한 토큰과 동일한지 확인할 수 있으며, 토큰의 내용이 예상한 대로인지를 확인할 수 있습니다.
컨트롤러에서 출력문을 지정한 사용자 이름과 권한이 잘 출력되고 있습니다.
'Framework > Spring\Spring boot' 카테고리의 다른 글
[Spring MVC] Filter: 웹 애플리케이션 보안을 위한 필터의 역할과 활용법 (0) | 2024.08.01 |
---|---|
[Spring Cloud] Spring Cloud란 무엇일까 ❓ (마이크로서비스 아키텍처(MSA)를 위한 프레임워크 ) (0) | 2024.07.31 |
[Spring MVC]인증과 인가란 ❓, 쿠키와 세션란❓(Authentication, Authorization, @CookieValue, HttpServletEuquest, HttpServletResponse) (0) | 2024.07.29 |
[Spring Boot] 같은 타입의 Bean이 두 개 이상일 때 처리 방법 (0) | 2024.07.28 |
[Spring] Spring에서 빈 이름 결정 방식 (0) | 2024.07.28 |