Angular UI 애플리케이션에서 로그인 후 JSESSIONID
와 XSRF-TOKEN
이라는 두 개의 쿠키가 생성된다.
이러한 쿠키는 무작위로 생성된 값으로, 토큰(Token)으로 간주된다.
로그인 후 생성되는 쿠키이다.
그리고 이후 모든 요청에서 엔드 유저가 자격증명을 입력하지 않아도 된다.
또한 토큰을 백엔드에 다시 보내지 않아도 된다. 쿠키로 저장되어 있기 때문이다.
그리고 백엔드에서 요청할 때마다 해당 쿠키는 브라우저에 의해 자동으로 첨부된다.
Angular의 dashboard.service.ts
안에서 API 요청 시 withCredentials:true
를 포함시키면 브라우저에 의해 쿠키를 가지고 백엔드로 쿠키에 대한 디테일 한 사항들을 보낼 것이다.
그리고 백엔드에서는 SessionID
와 쿠키 값을 이용할 것이고, 유저가 로그인 되었는지 안되었는지 확인이 가능하다.
CSRF 공격을 방지하기 위해 백엔드에서 생성되고 Angular 애플리케이션으로 보내진다.
토큰은 인증과 권한 부여에서 중요한 역할을 수행한다.
JSESSIONID는 작은 규모의 애플리케이션에 효과적이지만, 기업 규모의 애플리케이션에는 부족함이 있다.
첫 번째 이유는 JSESSIONID는 유저 데이터를 갖지 않으며, 브라우저 세션에 의존한다.
두 번째 이유는 브라우저를 닫지 않은 채로 유저 세션이 유효한 경우, 쿠키를 악용할 수 있는 보안 위협이 있다.
토큰은 범용 고유 식별자(UUID) 형식의 일반 문자열 또는 JSON Web Token(JWT)의 한 종류이다.
로그인 후 클라이언트 애플리케이션에 의해 보호된 자료를 접근할 때 사용된다.
엔드 유저의 인증이 완료되자 마자 로그인 작업 중 처음으로 토큰이 생성된다.
그러고 난 후에는 클라이언트가 보안된 API를 호출할 때마다 해당 토큰을 백엔드 서버로 보낸다.
백엔드 서버는 받은 토큰이 유효한지 확인하고, 유효하다면 올바른 응답을 반환한다.
토큰은 로그인 중에만 자격증명을 백엔드 서버로 보내야 하므로, 자주 자격증명을 반복해서 전송할 필요가 없다.
누군가 모든 토큰을 해킹한 사실을 알게 된다면 해당 토큰들을 단순히 무효화 시킨다.
짧은 수명을 갖도록 생성될 수 있다.
기업의 요구 사항에 따른 토큰을 생성하고 이에 대한 수명도 설정할 수 있다.
단순한 블로그 애플리케이션의 경우 훔칠 수 있는 정보가 없을 것입니다. 그렇다면 1년의 수명을 가진 토큰을 만들어도 무방하다.
하지만 은행 애플리케이션과 같이 더욱 예민한 애플리케이션의 경우에는 분명 짧은 수명의 접근 토큰(Access Token)을 만들 것이다.
토큰의 재사용성으로 인해 보안 취약점을 줄일 수 있으며, 짧은 수명을 설정하여 보안성을 높일 수 있다.
토큰을 사용하여 엔드 유저의 유저 정보 혹은 역할(role) 정보를 저장할 수 있다.
REST API 작업을 하면 이메일, 역할, 권한과 같은 엔드 유저에 대한 정보를 필요로 한다.
따라서 이러한 정보를 토큰 안에 포함 시킬 수 있는 것이다.
현재로서 Spring Security로 만들어진 JSESSIONID 토큰 안에서는 이러한 유연성이 없다. 그렇기 때문에 JWT 토큰과 같은 방법을 고려해야 한다.
토큰을 재사용 할 수 있다.
예를 들어 구글은 많은 내부 애플리케이션을 갖고 있다.
지메일, 구글 포토, 구글 지도 등의 한 조직에서 공통적으로 운영되는 애플리케이션들 말이다.
만약 엔드 유저가 지메일에서 구글 지도로 혹은 지메일에서 구글 드라이브로 이동할 때 구글이 자격증명을 계속해서 요구한다면 유저 친화적이지 못할 것이다.
따라서 내부적으로 지메일에서 구글 드라이브로 토큰이 보내질 것이고 해당 토큰은 구글 드라이에 의해 이용되며 승인 서버가 토큰에 대해 허락한다면 올바른 대답을 가질 것이다.
따라서 여기서 토큰이 여러 곳에서 재사용 되는 것을 볼 수 있고 토큰이 SSO (Single Sign-On)을 이루는 데에 있어 도움이 되는 것을 볼 수 있다.
토큰 덕분에 우리는 특히 마이크로서비스 환경에서 무상태(stateless)로 있을 수 있습니다
여기서 무상태란 클러스터 환경 같은 사이트 애플리케이션의 여러 인스턴스가 있을 때에서 로그인 중에 요청이 1번 인스턴스로 가고 이후의 요청들은 1번 인스터스로만 가지 않아도 되는 것이다.
2번 인스턴스, 3번 인스턴스 혹은 4번 인스턴스와 같이 클러스터 속 그 어떤 인스턴스에게 갈 수 있다.
그리고 다른 유저들이 로그인된 유저에 대해 알 수 있는 것도 토큰 덕분이다.
그리고 해당 인스턴스들은 세션 속 엔드 유저에 대해 특별히 기억하지 않아도 된다.
이것이 무상태(Stateless)이다.
인스턴스들이 마이크로서비스이며 세션 속에 엔드 유저에 대해 그 무엇도 저장하지 않아도 됩니다.
토큰이 유저에 대한 정보를 갖고 있을 것이기 때문에 무상태 상태가 될 수 있다.
JWT란 JSON Web Token의 약자이다.
JSON 형식으로 데이터를 유지한다.
UI 클라이언트와 백엔드 애플리케이션 간 통신에 사용된다.
REST 서비스와 함께 JSON 형식으로 통신 목적으로 쓰인다.
가장 많이 사용되고 선호되는 토큰 종류이다.
인증 및 권한 부여에 사용 가능하다.
토큰 내부에서 유저 관련 데이터 저장 및 공유한다.
서버 쪽 세션 안에 유저에 대한 정보를 갖고 있어야 하는 번거로움 해소 및 마이크로서비스 환경 유리하다. 이는 기억할 필요가 없고 완전히 무상태(Stateless)일 수 있다.
헤더(Header), 내용(Payload), 서명(Signature) 세 부분으로 구성
헤더: 토큰 메타데이터 저장
JWT 헤더 안에는 토큰에 대한 정보인 메타데이터가 저장되어 있다.
화면의 좌측에 보시다시피 JWT 토큰 속 모든 정보는 평문 형식으로 보내지지 않는다.
헤더 속에 제가 저장하고자 하는 메타데이터는 알고리즘("alg")으로 이는 HS256이며 형태("typ")는 JWT 이다.
JWT 토큰 속 이러한 정보를 클라이언트에게 직접 전송하는 대신 JWT 토큰이 추천하는 바는 메타데이터의 값을 Base64로 인코딩하는 것이다.
이렇게하면 변환이 될 것이고 화면 속 우측 상자에서 확인할 수 있다.
내용: 유저에 대한 모든 정보 저장
두번째 부분은 바디 혹은 내용(payload)라고 한다.
해당 부분 안에는 저장을 원하는 유저에 대한 모든 정보를 저장할 수 있다.
예시로 그의 이름, 이메일, 역할 토큰이 발행될 때 만료 시간이 언제인지 토큰을 발행한 자가 누군지 토큰을 서명한 자는 누군지 등이 있다.
따라서 JWT 토큰의 바디 혹은 내용에는 다양한 정보를 저장할 수 있습니다
여기서도 평문 형식을 Base64 Encoded 값으로 변환하여 전송한다.
서명(옵션): 토큰 변조 감지 및 검증 용도
JWT 토큰의 세 번째 부분인 서명은 클라이언트와 백엔드 간의 신뢰 관계에 따라 선택적으로 포함된다.
JWT 토큰이 인터넷 네트워크를 통해 전송될 때 보안 위협 존재한다. 이때 유저의 신원을 보호하기 위해 서명이 필요하다.
백엔드 애플리케이션이 해야할 일은 새로운 JWT 토큰을 생성할 때마다 성공적인 인증 이후에는 토큰에 디지털 서명을 해야 한다.
그렇다면 이를 어떻게 하느냐? SHA-256와 같이 알고리즘 중 하나의 도움을 받는다.
해당 알고리즘은 헤더, 내용 정보를 전달하며 이와 함께 백엔드 애플리케이션에만 알려져 있는 비밀 키를 사용한다.
위와 같이 알고리즘의 입력은 (base64UrlEncode(header)
그 뒤에 + "." 점
그리고 + base64UrlEncode(payload)
마지막으로는 , secret)
이다.
따라서 해당 비밀 값은 JWT 토큰을 발행하는 백엔드 애플리케이션만 알 수 있다.
서명 값의 일치 여부에 따라 토큰의 유효성을 확인한다.
서명이 일치하지 않으면 토큰을 조작하려는 시도가 있었음을 감지한다.
백엔드 서버에서 토큰 생성 시 헤더와 내용이 필수이다.
생성된 토큰은 데이터베이스나 서버 캐시에 저장하지 않아고 서명으로 유효성 검증 가능하다.
헤더(Header) 부분과 바디(Body/Payload)부분이 있다.
헤더와 바디/내용 그리고 비밀 값에 백엔드 애플리케이션은 서명 부분을 생성했을 수 있다.
그리고 해당 서명 부분은 내부적으로 해시 값을 갖고 있는 해시 문자열이다.
그리고 같은 토큰이 클라이언트 애플리케이션에게 다시 보내진다.
예를 들어 클라이언트 애플리케이션이 후속 요청을 다시 보내 온다고 가정해보자.
백엔드 애플리케이션에서는 이제 그 JWT 토큰을 클라이언트 애플리케이션 또는 엔드 유저로부터 받았기에 동일한 공식, 동일한 알고리즘 헤더의 동일한 Base64 URL 인코더 그리고 점(dot), 다음에 페이로드의 동일한 Base64 URL 인코더를 사용하여 초기에 사용된 것과 같은 비밀값과 함께 새로운 서명 해시를 계산하려 할 것이다.
따라서 출력은 또 새로운 서명 해시가 되는 것이다.
그리고 백엔드 애플리케이션은 새롭게 생성된 해시의 해시 값들을 JWT 토큰 자체에 있는 이미 생성된 해시 값과 비교할 것이다.
예를 들어 누군가가 헤더 혹은 내용를 조작하려 한다면 당연히 새로운 서명을 계산해내려 할 것이고 기존 서명 부분에 저장되어 잇는 것과는 다른 해시 값이 될 것이다.
저장하지 않고도 JWT 토큰이 조작 되었는지 확인할 수 있는 좋은 방법이다.
jwt.io
에 들어가 JWT 토큰을 입력하면 토큰의 헤더와 바디를 표시해준다.
서명은 해시된 문자열이기 때문에 디코딩 될 수 없어 표시할 수 없다. 이는 백엔드에서 서명을 확인하는데 활용할 공식이다.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
JSESSIONID
생성 및 저장하는 코드 제거현재 Spring Security의 프레임워크에 매번 JSESSIONID
를 생성하고 해당 ID를 UI 애플리케이션에 전송하도록 지시했으므로 초기 로그인 이후 UI 애플리케이션이 요청을 만들 때마다 동일한 JSESSIONID
를 활용할 수 있었다.
Spring Security 프레임워크에서 생성된 JSESSIOINID
보다는 직접 JWT 토큰
들을 생성하고 유효화하는 것으로 바꿔보자.
sessionManagement()
메소드 호출 매개변수를 받지 않는 sessionManagement()
메소드를 호출한다.
http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
해당 sessionManagement()
메소드 뒤에 sessionCreationPolicy()
메소드를 붙인 후 매개변수 안에는 SessionCreatePolicy.STATELESS
를 넣어준다.
이 설정은 무상태 세션 관리
를 의미
즉, 세션을 상태 없이 관리하겠다는 것이다. 매번 요청이 올 때마다 새로운 세션을 생성하지 않고, 요청을 처리할 때마다 세션을 유지하지 않겠다는 것이다. 따라서 매 요청마다 사용자의 인증 상태를 확인하고, 필요한 경우에만 인증을 수행하게 된다.
JWT 토큰들을 생성하기 시작할 때 해당 JWT 토큰들을 UI 애플리케이션으로 보내야 한다.
그리고 이러한 JWT 토큰을 UI 애플리케이션으로 권한 부여를 위해 Response(응답) 헤더에 설정 추가한다.
UI애플리케이션의 헤더를 노출하려 하는 것이기 때문에 응답 헤더를 보낼테니 받아달라고 브라우저에게 알려야 한다.
그렇지 않으면 브라우저가 해당 헤더를 받지 않고 이 승인 헤더 없이는 UI 애플리케이션이 JWT 토큰을 읽지 못하고 최초 로그인 이후 요청 중에 백엔드로 JWT 토큰을 보낼 수 없다.
그렇기 때문에 Cors 구성을 추가해야 한다.
config.setExposedHeaders(Arrays.asList("Authorization"));
setExposedHeaders()
메소드를 호출하고, 매개변수에 Arrays.asList("Authorization")
를 추가해 UI 애플리케이션에게 보내는 응답의 일부인 헤더의 이름이 무엇인지 보낸다.
내가 보낼 헤더 이름은 Authorization
(권한 부여)이다.
그리고 같은 헤더 안에 JWT 토큰 값도 함께 보낸다.
백엔드 애플리케이션에서 다른 출처에서 호스팅 되는 다른 UI 애플리케이션으로 헤더를 노출시키기 위해서는 이런 부분을 언급해야 한다.
엔드유저가 자격증명을 입력하여 웹 애플리케이션에 로그인하려 할 때마다 로그인이 성공적으로 완료 되자마자 JWT 토큰을 생성해야 한다.
생성할 JWTTokenGeneratorFilter
는 BasicAuthenticationFilter
다음에 넣기로 하자.
BasicAuthenticationFilter
후에만 로그인 동작이 완료될 수 있고, 로그인 동작이 완료되면 CSRF 토큰
이 생성되기 때문이다.
OncePerRequestFilter
클래스를 상속하여 새로운 Java 클래스 생성: JWTTokenGeneratorFilter
각 요청당 한 번만 실행되도록 한다.
package com.eazybytes.springsecsection2.filter;
import com.eazybytes.springsecsection2.constants.SecurityConstants;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.crypto.SecretKey;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
public class JWTTokenGeneratorFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
//현재 인증된 유저의 세부정보를 Authentication(인증) 객체의 형태로 제공
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//authentication(인증)이 null이 아닌 경우 (존재할 경우)
if (null != authentication) {
//SecurityConstants 내의 JWT_KEY를 가지고 비밀 키를 생성
//SecretKey는 JWT 토큰 라이브러리에서 import
SecretKey key = Keys.hmacShaKeyFor(SecurityConstants.JWT_KEY.getBytes(StandardCharsets.UTF_8));
//JWT 토큰 생성
//builder 메소드 이후, 몇몇 메소드를 호출
//issuer(): 해당 JWT 토큰을 발행하는 개인(혹은 조직)
//subject(): JWT Token으로 두어 원하는 값을 써줌
String jwt = Jwts.builder().issuer("Eazy Bank").subject("JWT Token")
//claim(): 로그인된 유저의 ID, 권한을 채워줌
.claim("username", authentication.getName())
.claim("authorities", populateAuthorities(authentication.getAuthorities()))
//issuedAt(): 클라이언트에게 JWT 토큰이 발행시간 설정
.issuedAt(new Date())
//expiration(): 클라이언트에게 JWT 토큰이 만료시간 설정
.expiration(new Date((new Date()).getTime() + 30000000))
//signWith(): JWT 토큰 속 모든 요청에 디지털 서명을 하는 것, 여기서 위에서 설정한 비밀키를 대입
.signWith(key).compact();
//헤더에 해당 토큰을 담아 응답에 보냄
//헤더 이름은 JWT_HEADER="Authorization"
response.setHeader(SecurityConstants.JWT_HEADER, jwt);
}
//체인 내의 다음 필터를 호출
filterChain.doFilter(request, response);
}
//OncePerRequestFilter의 shouldNotFilter() 메소드를 오버라이딩, 이는 디폴트가 false
//목적: 이 메소드에 조건을 제공한다면 그 조건에 따라 필터를 실행하지 않도록 하는 것
/**
* 현재 요청 경로가 /user라면 해당 값은 true가 될 것이고, 그렇지 않으면 false
* false라면 Spring Security 필터는 /user 요청 경로에서 실행되도록 할 것
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
//JWT 토큰 생성 필터는 오로지 로그인 과정 중에만 실행
//후속 요청에서 토큰이 계속해서 생성되는 것을 막기 위함
return !request.getServletPath().equals("/user");
}
//
private String populateAuthorities(Collection<? extends GrantedAuthority> collection) {
Set<String> authoritiesSet = new HashSet<>();
//내 모든 권한을 읽어옴
for (GrantedAuthority authority : collection) {
authoritiesSet.add(authority.getAuthority());
}
//String value로 "," 를 구분자로 권한들을 구분
return String.join(",", authoritiesSet);
}
}
doFilterInternal()
메소드 구현 부의 주석을 따라가며 로직을 살펴보자.
package com.eazybytes.springsecsection2.constants;
public interface SecurityConstants {
//비밀 키를 가진 JWT 키 : 백엔드만 알고 있음
//여기서는 직접적으로 constant 파일들을 언급하고 있지만 이상적인 프로덕션 환경에선 배포 때 DevOps가 이 값을 런타임 중 주입(Injection)해야 함
//GitHub Action이나 Jenkins와 같은 CI/CD 도구를 사용하여 환경 변수로 설정하거나 프로덕션 서버 내에서 이를 환경 변수로 구성할 수 있다.
public static final String JWT_KEY = "jxgEQeXHuPq8VdbyYFNkANdudQ53YUn4";
public static final String JWT_HEADER = "Authorization";
}
secret key
(비밀 키)를 생성하기 위해 SecurityConstants 인터페이스
를 직접 만들었다. JWT_KEY와 JWT_HEADER로 구성되어 있다.
JWT_HEADER는 헤더의 이름인 "Authorization"를 상수로 지정하였다.
public static final String JWT_KEY = "jxgEQeXHuPq8VdbyYFNkANdudQ53YUn4";
여기서는 직접적으로 constant 파일들을 언급하고 있지만 이상적인 프로덕션 환경에선 배포 때 DevOps가 이 값을 런타임 중 주입(Injection)해야 한다.
GitHub Action이나 Jenkins와 같은 CI/CD 도구를 사용하여 환경 변수로 설정하거나 프로덕션 서버 내에서 이를 환경 변수로 구성할 수 있다.
addFilterAfter()
메소드를 호출하고 싶은 이유는 성공적인 인증 이후 JWTTokenGeneratorFilter
필터가 실행되도록 하기 위해서이다.
최초 로그인 과정 중 JWTTokenValidatorFilter
의 도움으로 JWT 토큰을 생성할 때 같은 JWT 토큰을 가지고 UI 애플리케이션에 후속 요청들을 전부 검증할 수 있도록 해보자.
filter 패키지 안에서 새로운 Java 클래스 생성: JWTTokenValidatorFilter
OncePerRequestFilter
클래스를 상속하여 필터 생성
package com.eazybytes.springsecsection2.filter;
import com.eazybytes.springsecsection2.constants.SecurityConstants;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.crypto.SecretKey;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class JWTTokenValidatorFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
//JWT_HEADER = "Authorization" : 권한 부여 헤더 값을 가져옴(클라이언트가 요청 속에 보내는 JWT 토큰을 가져옴)
String jwt = request.getHeader(SecurityConstants.JWT_HEADER);
//JWT 토큰이 있다면
if (null != jwt) {
try {
//비밀키를 다시 생성
//SecurityConstants 내의 JWT_KEY를 가지고 비밀 키를 생성
//JWT 생성 시 만들었던 비밀 키와 동일, 같은 비밀 키를 다시 사용하고 있는지 확인하는 과정
SecretKey key = Keys.hmacShaKeyFor(
SecurityConstants.JWT_KEY.getBytes(StandardCharsets.UTF_8));
//Jwts 클래스를 이용해 parser() 메소드 호출
Claims claims = Jwts.parser()
//verifyWith(): key 같은 값을 보냄
.verifyWith(key)
.build()
//parseSignedClaims(): 받은 JWT 토큰 보냄
.parseSignedClaims(jwt)
//JWT 바디 값을 읽어보자, 특정 값을 나타내는 토큰 값이라면 헤더에서 서명 부분을 읽고 싶지 않은 것이다
//getPayload() 메소드에서 claims를 가져옴
.getPayload();
//claims을 이용해 엔드유저의 ID과 권한을 가져옴
String username = String.valueOf(claims.get("username"));
String authorities = (String) claims.get("authorities");
//UsernamePasswordAuthenticationToken 종류의 인증 객체를 생성
//username, 비밀번호는 null, authority를 볼 수 있음
Authentication auth = new UsernamePasswordAuthenticationToken(username, null,
AuthorityUtils.commaSeparatedStringToAuthorityList(authorities));
//해당 인증객체 auth를 SecurityContextHolder 안에 넣어둠
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
throw new BadCredentialsException("Invalid Token received!");
}
}
//체인 내의 다음 필터를 호출
filterChain.doFilter(request, response);
}
//요구사항-> validation 필터가 로그인 작업 중을 제외한 모든 API 호출에 대해 실행하는 것
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return request.getServletPath().equals("/user");
}
}
클라이언트 애플리케이션에서 받은 JWT 토큰의 유효성을 검증하는 로직 구현
클라이언트 요청에서 JWT 토큰 추출
헤더에서 권한 부여 헤더 값을 가져와서 JWT 토큰 추출
JWT 토큰을 사용하여 비밀 키를 다시 생성하고, 토큰 검증
JWT 토큰의 클레임에서 사용자 ID와 권한 추출
사용자 ID와 권한으로부터 UsernamePasswordAuthenticationToken
인증
객체 생성 및 설정
SecurityContextHolder
에 인증 객체 설정
로그인 중인 경우에는 JWT 토큰 검증 필터가 실행되지 않도록 설정
ProjectSecurityConfig 클래스
의 addFilterBefore()
메소드를 사용하여 필터 추가
JWTTokenValidatorFilter
를 BasicAuthenticationFilter
이전에 실행
커스텀 필터가 Spring Security의 실제 인증 유효성 검사 이전에 실행된다는 것이다.
해당 validateUser()
메소드 안에 엔드 유저의 성공적인 인증 이후에 이 responseData
안에 모든 응답을 받게 될 것이다
해당 responseData
로부터 Authorization
라는 이름으로 된 헤더를 읽어야 한다.
따라서 코드를 한 줄을 추가해보자.
우선 null
이 아니라면 Authorization
라는 이름의 헤더 값을 가져온다.
그리고 sessionStorage
안에 아이템 이름을 Authorization
으로 저장한다.
따라서 이 코드는 최초의 성공적인 로그인 과정 이후 받게 되는 JWT 토큰을 읽게 하는 것이다.
responseData
에서 Authorization
헤더 값을 읽어 sessionStorage
에 저장
이제 헤더 이름이 Authorization
인 요청으로 같은 JWT 토큰을 보내야 한다.
우리는 모든 종류의 요청에 대해 보내고 싶기 때문에 가장 적합한 곳은 여기 있는 interceptor 클래스
이다.
app.request.interceptor.ts
인 해당 파일을 열어보면 특히 비밀번호가 null
이 아닌 로그인 과정 중에 if 블록 속에 username, password를 Authorization 헤더
의 일부에 추가하여 보낸다.
하지만 나는 해당 값들을 Basic
으로 보내고 username과 password의 Basic64 형식
으로 보낸다.
하지만 모든 후속 작업은 JWT 토큰을 전송해야 한다.
sessionStorage
에서 Authorization 헤더 값
읽어온다.
Authorization
값이 null
이 아닌 경우 해당 값과 동일한 Authorization 헤더
를 요청에 추가
한다.
이로써 클라이언트 애플리케이션의 변경 사항이 모두 적용되었다. 이 변경 사항들이 제대로 작동하는지 테스트하고 검증해보자.
로그인 후 개발자 콘솔을 열어보면 CSRF 토큰
만 생성되어 쿠키에 저장되는 것을 확인할 수 있다. (=> XSRF-TOKEN)
Authorization
로 된 헤더를 볼 수 있다. 이는 백엔드 애플리케이션에 의해 생성된 JWT 토큰
이다.
해당 JWT 토큰
을 가지고 jwt.io 사이트
에 가서 같은 토큰을 붙여넣기 해보면 헤더와 내용을 검증할 수 있다.
여기서 iat
는 issued at
이라는 뜻으로 JWT 토큰
이 발행되었던 시간
이다.
여기서 exp
위에 커서를 놓으면 시간을 볼 수 있다. 만료 시간이 8시간 정도로 설정되어 있는 것을 확인할 수 있다.
로그인 한 후의 JWT 토큰과 /myBalance로 페이지를 이동한 후 JWT 토큰이 동일할 것을 확인할 수 있다.
JWT 토큰을 조작해보자.
JWTTokenValidatorFilter
첫 줄에 중단점을 놓아 디버깅 한 후 JWT 토큰을 set Value를 클릭해 값을 변경해보자. 그리고 catch 블록 속에 중단점을 두고 상황을 지켜보자.
SignatureException
이라는 예외를 받은 것을 확인할 수 있다.
이는 JWT 서명이 로컬 시스템 혹은 디바이스에서 계산된 서명과 일치하지 않다는 것을 알려준다.
이렇게 백엔드에선 누군가가 JWT 토큰을 조작하는 것을 빠르게 캐치할 수 있다.
토큰 수명을 30초로 수정하여 토큰 만료 상황 모의 실험 진행한다.
로그인 후 30초 대기 후 토큰 만료 확인한다.
해당 예외는 ExpiredJwtException
으로 JWT 토큰이 만료되었음을 의미한다.
그렇기 때문에 500번 에러가 발생한다.