세션은 인증된 사용자 정보를 서버 측 세션 저장소에서 관리한다.
생성된 사용자 세션의 고유한 ID인 세션 ID는 클라이언트의 쿠키에 저장되어 요청을 보낼 때 인증된 사용자인지를 증명하는 수단으로 사용된다.
클라이언트 측에서는 세션 ID만을 사용하기 때문에, 네트워크 트래픽 부담이 비교적 적다.
세션 정보는 서버 측에서 관리되기 때문에, 보안 측면에서 약간의 이점이 있다.
서버 확장성 측면에서는 세션 불일치 문제가 발생할 가능성이 있다.
세션 데이터 양이 증가하면 서버 부하가 증가할 수 있다.
SSR(Server Side Rendering) 애플리케이션에 적합한 방식이다.
세션기반 인증방식으로 생기는 서버의 부담을 "클라이언트에게 넘겨줄순 없을까?"하는 생각에서 토큰 기반 인증이 고안되었다.
(대표적인 토큰기반 인증 = JWT)
클라이언트 측에 인증된 사용자의 정보를 토큰 형태로 저장하는 방식
토큰에 포함된 인증된 사용자 정보는 서버 측에서 별도로 관리되지 않는다.
생성된 토큰을 헤더에 포함시켜 요청을 보낼 때, 인증된 사용자인지를 증명하는 수단으로 사용된다.
토큰은 인증된 사용자 정보 등을 포함하기 때문에 세션보다 비교적 많은 네트워크 트래픽을 사용한다.
토큰은 기본적으로 서버 측에서 관리되지 않기 때문에 보안 측면에서 약간의 불리함이 있다.
인증된 사용자 요청의 상태를 유지할 필요가 없기 때문에 세션 불일치와 같은 문제를 일으키지 않으므로 서버의 확장성 측면에서 이점이 있다.
토큰에 포함된 사용자 정보는 토큰의 특성상 암호화되어 있지 않기 때문에, 공격자가 토큰을 탈취하면 사용자 정보가 그대로 제공된다. 따라서 민감한 정보는 토큰에 포함시키지 않아야 한다.
토큰은 기본적으로 만료될 때까지 무효화될 수 없다.
CSR(Client Side Rendering) 기반 애플리케이션에 적합한 방식이다.
JWT
- 가장 범용적으로 사용되는 토큰 인증 방식
- JSON 포맷의 토큰 정보를 인코딩 후, 인코딩된 토큰 정보를 Secret Key로 서명(Sign)한 메시지를 Web Token으로써 인증 과정에 사용
Access Token의 유효기간이 만료된다면 Refresh Token을 사용하여 새로운 Access Token을 발급받는다.
Refresh Token을 탈취당한다면 Access Token을 계속 발급할 수 있기 때문에 보안상의 문제가 있다.
{
"alg": "HS256",
"typ": "JWT"
}
// 이 JSON 객체를 base64 방식으로 인코딩하면 JWT의 Header가 된다.
{
"sub": "홍길동은 잘생겼다",
"name": "홍길동",
"iat": 151623391
}
// 이 JSON 객체를 base64 방식으로 인코딩하면 JWT의 Payload가 된다.
토큰의 위변조 유무를 검증하는 데 사용
base64로 Header와 Payload 부분을 인코딩한 뒤 원하는 비밀 키(Secret Key)와 Header에서 지정한 알고리즘을 사용하여 Header와 Payload에 대해서 단방향 암호화를 수행
HMACSHA256(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret);
JWT는 일반적으로 다음과 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token)을 사용자의 자격 증명에 이용한다.
Access Token에는 비교적 짧은 유효 기간 을 주어 탈취되더라도 오랫동안 사용할 수 없도록 하는 것이 권장된다.
JWT는 Header.Payload.Signature의 구조로 이루어진다.
Base64로 인코딩되는 Payload는 손쉽게 디코딩이 가능하므로 민감한 정보는 포함하지 않아야 한다.
상태를 유지하지 않고(Stateless), 확장에 용이한(Scalable) 애플리케이션을 구현하기 용이하다.
클라이언트가 request를 전송할 때마다 자격 증명 정보를 전송할 필요가 없다.
인증 시스템을 다른 플랫폼으로 분리하기 용이하다.
Payload를 디코딩하기 쉬워 보안이 취약하다.
토큰 길이가 길어지면 네트워크 부하가 증가될 가능성이 있다.
토큰이 자동으로 삭제되지 않는다.
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key) // 서명에 사용된 Secret Key를 설정
.build()
.parseClaimsJws(jws); // JWT를 파싱해서 Claims를 얻어내기 // (jws)는 Signature가 포함된 JWT라는 의미
}
// parseClaimsJws(jws)메서드 자체가 검증하는 로직이다.
// jws에서 Claims을 얻어내 base64EncodedSecretKey와 같은지 비교
Claims
- JWT의 내용(payload)에 포함된 JSON 형태의 객체
@DisplayName("throw ExpiredJwtException when jws verify")
@Test
public void verifyExpirationTest() throws InterruptedException { // 토큰 만료되고 ExpiredJwtException이 발생하는지 테스트
String accessToken = getAccessToken(Calendar.SECOND, 1);
assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey)); // 여기서는 통과
TimeUnit.MILLISECONDS.sleep(1500);
assertThrows(ExpiredJwtException.class, () -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey)); // 여기서는 1초 지났으니 토큰만료되고 실패
}
throws InterruptedException이 붙은 이유
- InterruptedException은 sleep() 메서드에서 발생할 수 있는 예외다. 이 메서드에서는 스레드를 1.5초 동안 중지한 후 ExpiredJwtException이 발생하는지 테스트하기 위해 사용되었다. 이러한 경우, 다른 스레드가 현재 실행중인 스레드를 깨울 수도 있으므로 InterruptedException을 처리해줘야 한다.
Plain Text 자체를 Secret Key로 사용하는 것은 권장되지 않는다.
jjwt 최신 버전(0.11.5)에서는 서명 과정에서 HMAC 알고리즘을 직접 지정하지 않고, 내부적으로 적절한 HMAC 알고리즘을 지정해 준다.
~~~
jwt:
key: ${JWT_SECRET_KEY} # 민감한 정보는 시스템 환경 변수에서 로드한다.
access-token-expiration-minutes: 30
refresh-token-expiration-minutes: 420
JWT의 서명에 사용되는 Secret Key 정보는 민감한(sensitive) 정보이므로 시스템 환경 변수의 변수로 등록
${JWT_SECRET_KEY}
는 단순한 문자열이 아니라 OS의 시스템 환경 변수의 값을 읽어오는 일종의 표현식이다.시스템 환경 변수에 등록한 변수를 사용할 때는 applicatioin.yml 파일의 프로퍼티 명과 동일한 문자열을 사용하지 않도록 주의해야 한다.
처음에는 환경변수에 null값이 들어가는 등 인식이 제대로 안되는 것 같았다.
jwt:
key: ${JWT_SECRET_KEY} # 민감한 정보는 시스템 환경 변수에서 로드한다.
#key: "#{systemEnvironment['JWT_SECRET_KEY']}" # 시스템 환경 변수에서 로드하는 방법(JWT_SECRET_KEY라는 이름의 환경변수를 가져오는 것)
access-token-expiration-minutes: 30
refresh-token-expiration-minutes: 420
위와 같은 방법으로 추가해도 되긴 하는데... 시스템 환경변수 추가 후 인텔리제이 재부팅을 안해서 인식이 안됐던 것 같다.
// 추가로 발생했던 오류문
io.jsonwebtoken.security.WeakKeyException: The specified key byte array is 232 bits which is not secure enough for any JWT HMAC-SHA algorithm.
The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HMAC-SHA algorithms MUST have a size >= 256 bits (the key size must be greater than or equal to the hash output size).
Consider using the io.jsonwebtoken.security.Keys#secretKeyFor(SignatureAlgorithm) method to create a key guaranteed to be secure enough for your preferred HMAC-SHA algorithm.
See https://tools.ietf.org/html/rfc7518#section-3.2 for more information.
환경 변수 인식 후에도 위와 같은 오류가 발생했다.
이 오류는 JWT(JSON Web Token)를 생성하는데 사용되는 Secret Key의 길이가 너무 짧아서 발생하는 오류다.
보안을 위해서 JWT JWA Specification (RFC 7518, Section 3.2)에서는 HMAC-SHA 알고리즘에 사용되는 Secret Key의 길이는 256 비트 이상이어야 한다고 규정하고 있다.
즉, 내가 환경변수 시크릿 키 길이를 너무 짧게 설정한 것이다.
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
public SecurityConfiguration(JwtTokenizer jwtTokenizer) {
this.jwtTokenizer = jwtTokenizer;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin() // 동일 출처로부터 들어오는 request만 페이지 렌더링을 허용 (H2 웹 콘솔(개발단계용으로) 쓰기 위해 추가한거)
.and()
.csrf().disable() // CSRF공격에 대한 Spring Security에 대한 설정을 비활성화
.cors(withDefaults()) // CORS 설정 추가 (corsConfigurationSource라는 이름으로 등록된 Bean을 이용)
.formLogin().disable() // 폼 로그인 방식을 비활성화
.httpBasic().disable() // HTTP Basic 인증 방식을 비활성화
.apply(new CustomFilterConfigurer()) // Custom Configurer 적용
.and()
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll() // 모든 HTTP request 요청에 대해서 접근 허용
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // PasswordEncoder Bean 객체 생성
}
// CORS 정책 설정하는 방법
@Bean
CorsConfigurationSource corsConfigurationSource() { // CorsConfigurationSource Bean 생성을 통해 구체적인 CORS 정책을 설정
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*")); // 모든 출처(Origin)에 대해 스크립트 기반의 HTTP 통신을 허용하도록 설정
configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE")); // 파라미터로 지정한 HTTP Method에 대한 HTTP 통신을 허용
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); // CorsConfigurationSource 인터페이스의 구현 클래스인 UrlBasedCorsConfigurationSource 클래스의 객체를 생성
source.registerCorsConfiguration("/**", configuration); // 모든 URL에 앞에서 구성한 CORS 정책(CorsConfiguration)을 적용
return source;
}
// Custom Configurer 클래스 (JwtAuthenticationFilter를 등록하는 역할)
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> { // AbstractHttpConfigurer를 상속해서 Custom Configurer를 구현할 수 있다.
@Override
public void configure(HttpSecurity builder) throws Exception { // configure() 메서드를 오버라이드해서 Configuration을 커스터마이징
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); // AuthenticationManager 객체 가져오기
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); // JwtAuthenticationFilter를 생성하면서 JwtAuthenticationFilter에서 사용되는 AuthenticationManager와 JwtTokenizer를 DI
jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login"); // setFilterProcessesUrl() 메서드를 통해 디폴트 request URL인 “/login”을 “/v11/auth/login”으로 변경
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler()); // 인증 성공시 수행할 작업 추가
jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler()); // 인증 실패시 수행할 작업 추가
// 빈등록으로 DI 안하고 new 쓴 이유는??
// 일반적으로 인증을 위한 Security Filter마다 AuthenticationSuccessHandler와 AuthenticationFailureHandler의 구현 클래스를 각각 생성할 것이므로 new 키워드를 사용해서 객체를 생성해도 무방하다.
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
builder.addFilter(jwtAuthenticationFilter); // addFilter() 메서드를 통해 JwtAuthenticationFilter를 Spring Security Filter Chain에 추가
}
}
}
UsernamePasswordAuthenticationFilter
를 이용해서 JWT 발급 전의 로그인 인증 기능을 구현할 수 있다.
Spring Security에서는 개발자가 직접 Custom Configurer를 구성해 Spring Security의 Configuration을 커스터마이징(customizations) 할 수 있다.
Username/Password 기반의 로그인 인증은 OncePerRequestFilter
같은 Spring Security에서 지원하는 다른 Filter를 이용해서도 구현할 수 있으며, Controller에서 REST API 엔드포인트로 구현하는 것도 가능하다.
Spring Security에서는 Username/Password 기반의 로그인 인증에 성공했을 때, 로그를 기록하거나 로그인에 성공한 사용자 정보를 response로 전송하는 등의 추가 처리를 할 수 있는 AuthenticationSuccessHandler
를 지원하며, 로그인 인증 실패 시에도 마찬가지로 인증 실패에 대해 추가 처리를 할 수 있는 AuthenticationFailureHandler
를 지원한다.
JWT의 검증은 request 당 단 한 번만 수행하면 되기 때문에 OncePerRequestFilter
를 이용해 한번만 수행하도록 한다.
OncePerRequestFilter
의 shouldNotFilter()
를 오버라이드해서 특정 조건에 부합하면(true이면) 해당 Filter의 동작을 수행하지 않고 다음 Filter로 건너뛰도록 할 수 있다.JWT에서 Claims를 파싱할 수 있다는 의미는 내부적으로 서명(Signature) 검증에 성공했다는 의미다.
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey); // base64로 인코딩된 Secret Key를 디코딩하여 Key 객체 얻기
Jws<Claims> claims = Jwts.parserBuilder()// JwtParserBuilder 인스턴스를 생성해서 JWT 파싱에 필요한 설정을 지정
.setSigningKey(key) // 서명 검증에 사용할 시크릿키를 설정
.build() // JwtParser 객체 생성
.parseClaimsJws(jws); // 입력으로 받은 JWT 토큰 문자열을 파싱+key와 비교해 검증
return claims; // 클레임(토큰 데이터)을 포함하는 Jws<Claims> 객체를 반환
}
Authentication
을 저장하게 되면 Spring Security의 세션 정책(Session Policy)에 따라서 세션을 생성할 수도 있고, 그렇지 않을 수도 있다.JwtVerificationFilter
(JWT인증필터)를 사용하기 위해서는 아래와 같은 두 가지 설정을 SecurityConfigruation
클래스에 추가해야 한다.
SessionCreationPolicy()
의 설정값으로는 아래와 같이 총 네 개의 값을 사용할 수 있다.
SessionCreationPolicy() 의 설정값
SessionCreationPolicy.ALWAYS
- 항상 세션을 생성
SessionCreationPolicy.NEVER
- 세션을 생성하지 않지만 만약에 이미 생성된 세션이 있다면 사용
SessionCreationPolicy.IF_REQUIRED
- 필요한 경우에만 세션을 생성
SessionCreationPolicy.STATELESS
- 세션을 생성하지 않으며, SecurityContext 정보를 얻기 위해 세션을 사용하지 않는다.
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> { // AbstractHttpConfigurer를 상속해서 Custom Configurer를 구현할 수 있다.
@Override
public void configure(HttpSecurity builder) throws Exception { // configure() 메서드를 오버라이드해서 Configuration을 커스터마이징
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); // AuthenticationManager 객체 가져오기
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); // JwtAuthenticationFilter를 생성하면서 JwtAuthenticationFilter에서 사용되는 AuthenticationManager와 JwtTokenizer를 DI
jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login"); // setFilterProcessesUrl() 메서드를 통해 디폴트 request URL인 “/login”을 “/v11/auth/login”으로 변경
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler()); // 인증 성공시 사용할 객체 등록
jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler()); // 인증 실패시 사용할 객체 등록
// 빈등록으로 DI 안하고 new 쓴 이유는??
// 일반적으로 인증을 위한 Security Filter마다 AuthenticationSuccessHandler와 AuthenticationFailureHandler의 구현 클래스를 각각 생성할 것이므로 new 키워드를 사용해서 객체를 생성해도 무방하다.
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils); // JwtVerificationFilter의 인스턴스를 생성하면서 JwtVerificationFilter에서 사용되는 객체들을 생성자로 DI
builder.addFilter(jwtAuthenticationFilter) // addFilter() 메서드를 통해 JwtAuthenticationFilter를 Spring Security Filter Chain에 추가
.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class); // JwtVerificationFilter는 JwtAuthenticationFilter에서 로그인 인증에 성공한 후 발급 받은 JWT가 클라이언트의 request header(Authorization 헤더)에 포함되어 있을 경우에만 동작한다.
}
}
여기서 추가한 JwtVerificationFilter는 JwtAuthenticationFilter에서 로그인 인증에 성공한 후 발급 받은 JWT가 클라이언트의 request header(Authorization 헤더)에 포함되어 있을 경우에만 동작한다.
// 방법1. SecurityConfiguration에 필터로 권한 판단
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin() // 동일 출처로부터 들어오는 request만 페이지 렌더링을 허용 (H2 웹 콘솔(개발단계용으로) 쓰기 위해 추가한거)
.and()
.csrf().disable() // CSRF공격에 대한 Spring Security에 대한 설정을 비활성화
.cors(withDefaults()) // CORS 설정 추가 (corsConfigurationSource라는 이름으로 등록된 Bean을 이용)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 생성하지 않도록 설정
.and()
.formLogin().disable() // 폼 로그인 방식을 비활성화
.httpBasic().disable() // HTTP Basic 인증 방식을 비활성화
.apply(new CustomFilterConfigurer()) // Custom Configurer 적용
.and()
.authorizeHttpRequests(authorize -> authorize
// .anyRequest().permitAll() // 모든 HTTP request 요청에 대해서 접근 허용
.antMatchers(HttpMethod.POST, "/*/members").permitAll() // 누구나 접근 가능
.antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER") // USER권한 있눈 사용자만
.antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN")
.antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER")
.antMatchers(HttpMethod.POST,"/*/coffees").hasRole("ADMIN")
.antMatchers(HttpMethod.PATCH,"/*/coffees/**").hasRole("ADMIN")
.antMatchers(HttpMethod.GET,"/*/coffees").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.GET,"/*/coffees/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.DELETE,"/*/coffees/**").hasRole("ADMIN")
.antMatchers(HttpMethod.POST,"/*/orders").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.PATCH,"/*/orders/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.GET,"/*/orders").hasRole("ADMIN")
.antMatchers(HttpMethod.GET,"/*/orders/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.DELETE,"/*/orders").hasAnyRole("USER", "ADMIN")
.anyRequest().permitAll() // 위에 설정한 요청 외의 모든 요청 허용
);
return http.build();
}
주문 관련 권한 적용 중 본인이 주문한 주문내역에 대해서만 조회, 수정, 삭제가 가능하도록 로직을 추가하고 싶어 아래 방법으로 기능을 추가했다.
//방법2. @PreAuthorize 애너테이션을 이용해 service클래스의 각 메서드별로 권한 체크
// @PreAuthorize("authentication.name == @orderRepository.findById(#order.orderId).member.username or hasRole('ADMIN')") // 이렇게 하면 예외처리 힘듬
@PreAuthorize("@orderService.isOrderOwnerOrAdmin(#order.orderId, authentication.name)")
public Order updateOrder(Order order) {
Order findOrder = findVerifiedOrder(order.getOrderId());
Optional.ofNullable(order.getOrderStatus())
.ifPresent(orderStatus -> findOrder.setOrderStatus(orderStatus));
return orderRepository.save(findOrder);
}
@PreAuthorize("@orderService.isOrderOwnerOrAdmin(#orderId, authentication.name)") // 사용자가 해당 주문의 주인인지 or 관리자인지 확인
public Order findOrder(long orderId) {
return findVerifiedOrder(orderId);
}
public Page<Order> findOrders(int page, int size) {
return orderRepository.findAll(PageRequest.of(page, size,
Sort.by("orderId").descending()));
}
// @PreAuthorize("authentication.name == @orderRepository.findById(#orderId).member.username or hasRole('ROLE_ADMIN')") // 이렇게 하면 예외처리 힘듬
@PreAuthorize("@orderService.isOrderOwnerOrAdmin(#orderId, authentication.name)")
public void cancelOrder(long orderId) {
Order findOrder = findVerifiedOrder(orderId);
int step = findOrder.getOrderStatus().getStepNumber();
// OrderStatus의 step이 2 이상일 경우(ORDER_CONFIRM)에는 주문 취소가 되지 않도록한다.
if (step >= 2) {
throw new BusinessLogicException(ExceptionCode.CANNOT_CHANGE_ORDER);
}
findOrder.setOrderStatus(Order.OrderStatus.ORDER_CANCEL);
orderRepository.save(findOrder);
}
JWT를 Authorization header에 포함하지 않을 경우
JWT를 Authorization header에 포함하지 않은 채 MemberController의 getMember() 핸들러 메서드에 request를 전달하면JwtVerificationFilter
를 건너뛰게 되고, 나머지 Security Filter에서 권한 체크를 하면서 적절한 권한이 부여되지 않았기 때문에 403 status가 전달된다.
유효하지 않은 JWT를 Authorization header에 포함할 경우
접근 권한에 대한 에러를 나타내는 403 status가 발생했지만 JWT의 검증에 실패했기 때문에 자격 증명에 실패한 것과 같으므로 UNAUTHORIZED를 의미하는 401 status가 더 적절할 것 같다. 아래에서 이어서 예외처리를 해야겠다.
권한이 부여되지 않은 리소스에 request를 전송할 경우
ADMIN 권한에만 접근이 허용된 MemberController의 getMembers() 핸들러 메서드에 request를 전송하니JwtVerificationFilter
에서 JWT의 자격 증명은 정상적으로 수행되었지만 ADMIN 권한이 없는 사용자이므로 403 status가 전달되었다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try { // 예외처리 로직 추가
Map<String, Object> claims = verifyJws(request);
setAuthenticationToContext(claims);
} catch (SignatureException se) { // Exception이 catch 되면
request.setAttribute("exception", se); // 해당 Exception을 HttpServletRequest의 애트리뷰트(Attribute)로 추가
} catch (ExpiredJwtException ee) {
request.setAttribute("exception", ee);
} catch (Exception e) {
request.setAttribute("exception", e);
}
filterChain.doFilter(request, response);
}
예외가 발생하게 되면 SecurityContext에 클라이언트의 인증 정보(Authentication 객체)가 저장되지 않는다.
AuthenticationException
이 발생하게 될거다.JwtVerificationFilter
예외 처리에서는 일반적으로 알고 있는 예외 처리 방식과는 다르게 Exception을 catch한 후에 Exception을 다시 throw 한다든지 하는 처리를 하지 않고, 단순히 request.setAttribute()
를 설정하는 일밖에 하지 않는다.
SignatureException
, ExpiredJwtException
등 Exception 발생으로 인해 SecurityContext에 Authentication
이 저장되지 않을 경우 등 AuthenticationException
이 발생할 때 호출되는 핸들러 같은 역할// AuthenticationException(인증오류)이 발생할 때 호출되는 핸들러 같은 역할
@Slf4j
@Component
public class MemberAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override // 인증 요청이 실패했을 때 호출되는 메서드
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Exception exception = (Exception) request.getAttribute("exception"); // 어떤 오류인지 exception에 할당 (필터에서 저장했던 request의 Attribute 중 exception)
ErrorResponder.sendErrorResponse(response, HttpStatus.UNAUTHORIZED); // 클라이언트에게 401 응답 보내기
logExceptionMessage(authException, exception); // (인증 과정에서 발생한 예외 정보 or 요청 객체에서 얻어온 예외 정보) log로 남기기
}
private void logExceptionMessage(AuthenticationException authException, Exception exception) {
String message = exception != null ? exception.getMessage() : authException.getMessage(); // exception이 null이 아니면 전자, null이면 후자를 message에 할당
log.warn("Unauthorized error happened: {}", message);
}
}
// 클라이언트에게 전송할 ErrorResponse를 출력 스트림으로 생성하는 역할
public class ErrorResponder {
public static void sendErrorResponse(HttpServletResponse response, HttpStatus status) throws IOException {
Gson gson = new Gson();
ErrorResponse errorResponse = ErrorResponse.of(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE); // 응답의 컨텐츠 타입을 JSON으로 설정
response.setStatus(status.value()); // status 작성
response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class)); // response body 부분 작성
}
}
// 인증에는 성공했지만 해당 리소스에 대한 권한이 없으면 호출되는 핸들러
@Slf4j
@Component
public class MemberAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ErrorResponder.sendErrorResponse(response, HttpStatus.FORBIDDEN); // 클라이언트한테 응답
log.warn("Forbidden error happened: {}", accessDeniedException.getMessage()); // 발생한 예외 log로 남기기
}
}
@Configuration
@EnableWebSecurity // Spring Security를 사용하기 위한 필수 설정들을 자동으로 등록
@EnableGlobalMethodSecurity(prePostEnabled = true) // 메소드 보안 기능 활성화
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
public SecurityConfiguration(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin() // 동일 출처로부터 들어오는 request만 페이지 렌더링을 허용 (H2 웹 콘솔(개발단계용으로) 쓰기 위해 추가한거)
.and()
.csrf().disable() // CSRF공격에 대한 Spring Security에 대한 설정을 비활성화
.cors(withDefaults()) // CORS 설정 추가 (corsConfigurationSource라는 이름으로 등록된 Bean을 이용)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 생성하지 않도록 설정
.and()
.formLogin().disable() // 폼 로그인 방식을 비활성화
.httpBasic().disable() // HTTP Basic 인증 방식을 비활성화
------------------------------------예외처리 추가----------------------------------------------------------
.exceptionHandling()
.authenticationEntryPoint(new MemberAuthenticationEntryPoint()) // 인증오류가 발생할 때 처리해주는 핸들러 호출
.accessDeniedHandler(new MemberAccessDeniedHandler()) // 인증에는 성공했지만 해당 리소스에 대한 권한이 없을 때 처리해주는 핸들러 호출
.and()
------------------------------------예외처리 추가----------------------------------------------------------
.apply(new CustomFilterConfigurer()) // Custom Configurer 적용
.and()
.authorizeHttpRequests(authorize -> authorize
// .anyRequest().permitAll() // 모든 HTTP request 요청에 대해서 접근 허용
.antMatchers(HttpMethod.POST, "/*/members").permitAll() // 누구나 접근 가능
.antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER") // USER권한 있눈 사용자만
.antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN")
.antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER")
.antMatchers(HttpMethod.POST,"/*/coffees").hasRole("ADMIN")
.antMatchers(HttpMethod.PATCH,"/*/coffees/**").hasRole("ADMIN")
.antMatchers(HttpMethod.GET,"/*/coffees").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.GET,"/*/coffees/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.DELETE,"/*/coffees/**").hasRole("ADMIN")
.antMatchers(HttpMethod.POST,"/*/orders").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.PATCH,"/*/orders/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.GET,"/*/orders").hasRole("ADMIN")
.antMatchers(HttpMethod.GET,"/*/orders/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.DELETE,"/*/orders").hasAnyRole("USER", "ADMIN")
.anyRequest().permitAll() // 위에 설정한 요청 외의 모든 요청 허용
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // PasswordEncoder Bean 객체 생성
}
// CORS 정책 설정하는 방법
@Bean
CorsConfigurationSource corsConfigurationSource() { // CorsConfigurationSource Bean 생성을 통해 구체적인 CORS 정책을 설정
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*")); // 모든 출처(Origin)에 대해 스크립트 기반의 HTTP 통신을 허용하도록 설정
configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE")); // 파라미터로 지정한 HTTP Method에 대한 HTTP 통신을 허용
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); // CorsConfigurationSource 인터페이스의 구현 클래스인 UrlBasedCorsConfigurationSource 클래스의 객체를 생성
source.registerCorsConfiguration("/**", configuration); // 모든 URL에 앞에서 구성한 CORS 정책(CorsConfiguration)을 적용
return source;
}
// Custom Configurer 클래스 (JwtAuthenticationFilter를 등록하는 역할)
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> { // AbstractHttpConfigurer를 상속해서 Custom Configurer를 구현할 수 있다.
@Override
public void configure(HttpSecurity builder) throws Exception { // configure() 메서드를 오버라이드해서 Configuration을 커스터마이징
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); // AuthenticationManager 객체 가져오기
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); // JwtAuthenticationFilter를 생성하면서 JwtAuthenticationFilter에서 사용되는 AuthenticationManager와 JwtTokenizer를 DI
jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login"); // setFilterProcessesUrl() 메서드를 통해 디폴트 request URL인 “/login”을 “/v11/auth/login”으로 변경
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler()); // 인증 성공시 사용할 객체 등록
jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler()); // 인증 실패시 사용할 객체 등록
// 빈등록으로 DI 안하고 new 쓴 이유는??
// 일반적으로 인증을 위한 Security Filter마다 AuthenticationSuccessHandler와 AuthenticationFailureHandler의 구현 클래스를 각각 생성할 것이므로 new 키워드를 사용해서 객체를 생성해도 무방하다.
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils); // JwtVerificationFilter의 인스턴스를 생성하면서 JwtVerificationFilter에서 사용되는 객체들을 생성자로 DI
builder.addFilter(jwtAuthenticationFilter) // addFilter() 메서드를 통해 JwtAuthenticationFilter를 Spring Security Filter Chain에 추가
.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class); // JwtVerificationFilter는 JwtAuthenticationFilter에서 로그인 인증에 성공한 후 발급 받은 JWT가 클라이언트의 request header(Authorization 헤더)에 포함되어 있을 경우에만 동작한다.
}
}
}
SecurityContext에 Authentication
을 저장하게 되면 Spring Security의 세션 정책(Session Policy)에 따라서 세션을 생성할 수도 있고, 그렇지 않을 수도 있다.
SecurityContext에 클라이언트의 인증 정보(Authentication
객체)가 저장되지 않은 상태로 다음(next) Security Filter 로직을 수행하다 보면 결국에는 AuthenticationException
이 발생하게 되고, 이 AuthenticationException
은 AuthenticationEntryPoint
가 처리하게 된다.
AccessDeniedHandler
는 인증에는 성공했지만 해당 리소스에 대한 권한이 없으면 호출되는 핸들러이다.