2. 인증 프로세스
1. 폼 인증 - formLogin()
폼 인증
- HTTP 기반의 폼 로그인 인증 메커니즘을 활성화하는 API 로서 사용자 인증을 위한 사용자 정의 로그인 페이지를 쉽게 구현할 수 있다
- 기본적으로 스프링 시큐리티가 제공하는 기본 로그인 페이지를 사용하며 사용자 이름과 비밀번호 필드가 포함된 간단한 로그인 양식을 제공한다
- 사용자는 웹 폼을 통해 자격 증명(사용자 이름과 비밀번호)을 제공하고 Spring Security는 HttpServletRequest에서 이 값을 읽어 온다
폼 인증 흐름
- 1. 클라이언트 요청(/user)
- if. /user 요청 경로가 사용자 인증을 필요로 한다면?
- 2. 스프링 시큐리티의 여러 필터 중 가장 마지막에 위치한 AuthorizationFilter 가 현재 요청에 대해 사용자의 /user 경로로의 접근 유무 결정
- 3. 인증이 안됐으면 접근 예외 발생(AccessDeniedException)
- 4. ExceptionTranslationFilter 가 AccessDeniedException 를 받아서 예외를 처리
- 5. ExceptionTranslationFilter 의 여러 예외 처리 방식 중 AuthenticationEntryPoint 로 재인증 시도를 위한 로그인 페이지로 리다이랙트
formLogin() API
- FormLoginConfigurer 설정 클래스를 통해 여러 API 들을 설정할 수 있다
- 내부적으로 UsernamePasswordAuthenticationFilter 가 생성되어 폼 방식의 인증 처리를 담당하게 된다
HttpSecurity.formLogin(httpSecurityFormLoginConfigurer -> httpSecurityFormLoginConfigurer
.loginPage("/loginPage") // 사용자 정의 로그인 페이지로 전환, 기본 로그인페이지 무시
.loginProcessingUrl("/loginProc") //사용자 이름과 비밀번호를 검증할 URL지정 (Formaction)
.defaultSuccessUrl("/",[alwaysUse]) //로그인 성공 이후 이동 페이지,alwaysUse가 true이면 무조건 지정된 위치로 이동(기본은 false)
//인증 전에 보안이 필요한 페이지를 방문하다가 인증에 성공한 경우이면 이전 위치로 리다이렉트 됨
.failureUrl("/failed") //인증에 실패할 경우 사용자에게 보내질 URL을 지정,기본값은 "/login?error" 이다
.usernameParameter("username") //인증을 수행할 때 사용자 이름(아이디)을 찾기 위해 확인하는 HTTP매개변수 설정,기본값은 username
.passwordParameter("password") //인증을 수행할 때 비밀번호를 찾기 위해 확인하는 HTTP매개변수 설정,기본값은 password
.failureHandler(AuthenticationFailureHandler) //인증 실패 시 사용할 AuthenticationFailureHandler를 지정
//기본값은 SimpleUrlAuthenticationFailureHandler를 사용하여 "/login?error"로 리다이렉션 함
.successHandler(AuthenticationSuccessHandler) //인증 성공 시 사용할 AuthenticationSuccessHandler를 지정
//기본값은 SavedRequestAwareAuthenticationSuccessHandler이다
.permitAll() //failureUrl(),loginPage(),loginProcessingUrl()에 대한 URL에 모든 사용자의 접근을 허용 함
);
.loginPage("/loginPage")
- 사용자 정의 로그인 페이지로 전환, 기본 로그인페이지 무시
.loginProcessingUrl("/loginProc")
- 사용자 이름과 비밀번호를 검증할 URL지정 ( Form action - html <form> 의 action 속성 )
.defaultSuccessUrl("/",[alwaysUse])
- 로그인 성공 이후 이동 페이지, alwaysUse가 true이면 무조건 지정된 위치로 이동( 기본은 false )
- 인증 전에 보안이 필요한 페이지를 방문하다가 인증에 성공한 경우이면 이전 위치로 리다이렉트 됨
.failureUrl("/failed")
- 인증에 실패할 경우 사용자에게 보내질 URL을 지정,기본값은 "/login?error" 이다
.usernameParameter("username")
- 인증을 수행할 때 사용자 이름(아이디)을 찾기 위해 확인하는 HTTP 매개변수 설정,기본값은 username
.passwordParameter("password")
- 인증을 수행할 때 비밀번호를 찾기 위해 확인하는 HTTP 매개변수 설정,기본값은 password
.failureHandler(AuthenticationFailureHandler)
- 인증 실패 시 사용할 AuthenticationFailureHandler를 지정
- 기본값인 SimpleUrlAuthenticationFailureHandler를 사용하면 "/login?error"로 리다이렉션 함
.successHandler(AuthenticationSuccessHandler)
- 인증 성공 시 사용할 AuthenticationSuccessHandler를 지정
- 기본값은 SavedRequestAwareAuthenticationSuccessHandler이다
.permitAll()
- loginPage(), loginProcessingUrl(), failureUrl() 에 대한 URL에 모든 사용자의 접근을 허용 함
실습
SecurityConfig 수정
package com.example.springsecuritymaster;
@Slf4j
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/loginPage")
.loginProcessingUrl("/loginProc")
.defaultSuccessUrl("/", true)
.failureUrl("/failed")
.usernameParameter("userId")
.passwordParameter("passwd")
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("Authentication : {}", authentication);
response.sendRedirect("/home");
}
})
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
log.info("AuthenticationException : {}", exception.getMessage());
response.sendRedirect("/login");
}
})
.permitAll()
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password("{noop}1111")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
간단한 실습을 위해 Rest형으로 URL 테스트
IndexController
package com.example.springsecuritymaster;
@RestController
public class IndexController {
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/home") // 추가
public String home() {
return "home";
}
@GetMapping("/loginPage") // 추가
public String loginPage() {
return "login";
}
}
결과 확인
http://localhost:8080 요청
.loginPage("/loginPage") 결과
- 인증이 안된 요청이므로 SecurityConfig의 .loginPage("/loginPage") 로 설정한 페이지로 이동
.loginProcessingUrl("/loginProc")
.usernameParameter("userId")
.passwordParameter("passwd") 확인
- .loginPage("/loginPage") 주석 처리
- 스프링 시큐리티 기본 폼 로그인 페이지로 이동
- 기본 폼 로그인 소스 코드를 보면 위에서 설정한 값들이 태그에 정의된다.
.defaultSuccessUrl("/", [alwaysUse]) 확인
- http://localhost:8080/home 요청
- alwaysUse
- true : 어떠한 경우(/home으로 요청을 했을지라도) 라도 지정한 경로로 이동
- false : /home으로 요청을 했기 때문에 /home 으로 이동. 즉, 인증 전에는 보안이 필요한 페이지를 방문하다가 인증에 성공한 경우이면 이전 위치로 리다이렉트 됨
.successHandler(AuthenticationSuccessHandler)
.failureHandler(AuthenticationFailureHandler)
- .defaultSuccessUrl(), .failureUrl() 도 결국 내부적으로 AuthenticationSuccessHandler, AuthenticationFailureHandler 을 사용하기 때문에 우리가 간편하게 정의한 경로대로 동작하는 것이다.
- successHandler(AuthenticationSuccessHandler), .failureHandler(AuthenticationFailureHandler) 는 좀 더 구체적인 설정이므로 이를 정의했을 시 .defaultSuccessUrl(), .failureUrl() 는 무시된다.
FormLoginConfigurer 초기화 과정
HttpSecurity
- SecurityConfig 에서 formLogin을 customize 했기 때문에 아래 코드로 이동.
- new FormLoginConfigurer 생성
FormLoginConfigurer
- FormLoginConfigurer 생성자에서 인증 처리 필터인 UsernamePasswordAuthenticationFilter 생성
- username, password 기본 파라미터 설정
AbstractAuthenticationFilterConfigurer
Configurer의 주요 메소드인 init(), configure() 을 중심으로 살펴보자.
init()
초기화 과정에서 login 성공, 실패에 따른 기본 경로를 설정
permitAll() 설정 시 loginPage(), loginProcessingUrl(), failureUrl() 에 대한 URL에 모든 사용자의 접근을 허용
configure()
앞서 생성한 UsernamePasswordAuthenticationFilter를 this.authFilter에 할당하고 인증에 필요한 Handler 등을 추가하고 있다.
또한 Session, RememberMe 등 부가적인 설정들이 이뤄지고 마지막에 HttpSecurity에 필터를 추가해주고 있다.
UsernamePasswordAuthenticationFilter
- 스프링 시큐리티는 AbstractAuthenticationProcessingFilter 클래스를 사용자의 자격 증명을 인증하는 기본 필터로 사용 한다
- UsernamePasswordAuthenticationFilter 는 AbstractAuthenticationProcessingFilter 를 확장한 클래스로서 HttpServletRequest 에서 제출된 사용자 이름과 비밀번호 로부터 인증을 수행한다
- 인증 프로세스가 초기화 될 때 로그인 페이지와 로그아웃 페이지 생성을 위한 DefaultLoginPageGeneratingFilter 및 DefaultLogoutPageGeneratingFilter 가 초기화 된다
구조
AbstractAuthenticationProcessingFilter 가 클라이언트의 Get /login 요청을 받는다.
그러나 보통은 AbstractAuthenticationProcessingFilter 를 확장해서 인증처리를 진행한다.
이때 기본적으로 시큐리티가 제공하는 인증 필터가 UsernamePasswordAuthenticationFilter 이고 또는 우리가 AbstractAuthenticationProcessingFilter 를 직접 상속받아 커스텀은 필터를 만들어 인증 처리를 할 수도 있다.
(이때 attemptAuthentication() 메소드를 오버라이딩하여 인증 로직을 작성한다. )
흐름도
- 클라이언트 Get /login 요청
- UsernamePasswordAuthenticationFilter 의 RequestMatcher 가 요청 정보 ( Get /login )에 대해 인증 처리 유무를 검증.
- 기본적으로 RequestMatcher 에는 Get /login 정보가 저장되어 있다. 그래서 현재 클라이언트에서 요청한 정보가 자신이 갖고 있는 정보와 매칭이 되면 True를 반환한다.
- Authentication 인터페이스의 구현체인 UsernamePasswordAuthenticationToken 객체에 사용자가 입력한 Username + Password 를 저장한다.
- AuthenticationManager 는 UsernamePasswordAuthenticationToken 을 갖고 실제 인증 처리를 진행한다.
인증 성공
- UsernamePasswordAuthenticationToken 에 추가적인 UserDetails + Authorities 정보를 저장한다.
- 이때 UserDetails 타입의 객체 or 또 다른 User 객체 정보가 저장된다.
- Why? 인증을 성공했기 때문에 사용자의 ID, NAME 등 여러가지 정보들로 구성된 객체를 저장하는 것이다. (DB에서 읽어온 정보들)
- 즉, 최종 인증에 성공한 사용자의 정보와 권한들이 UsernamePasswordAuthenticationToken 에 저장된다.
- 이때 UserDetails 타입의 객체 or 또 다른 User 객체 정보가 저장된다.
- SessionAuthenticationStrategy 를 통해 새로운 로그인을 알리고 세션 관련 작업들을 수행한다.
- (중요!) 인증 상태 유지 ( SecurityConextHolder)
- 앞서 최종 인증에 성공한 사용자의 정보와 권한 정보가 담긴 UsernamePasswordAuthenticationToken (Authentication) 을 SecurityConext 에 설정한다.
- 세션에 SecurityConext 를 저장한다.
- 자동 로그인과 같은 기능 유무를 설정하기 위해 RememberMeService.loginSuccess를 호출하여 Remember-me를 설정한다.
- 인증 성공 이벤트 게시 ( ApplicationEventPulisher )
- 인증 성공 핸들러 호출 ( AuthenticationSuccessHandler )
인증 실패
- SecurityConextHolder 삭제
- 이전에 저장되어 있던 SecurityConext 가 있다면 삭제
- RememberMeService.loginFail 을 호출해서 자동 로그인 유무 삭제
- 인증 실패 핸들어 호출 ( AuthenticationFailHandler )
예외 처리
만약 인증 과정을 처리하는 도중 예외가 발생한다면 이때의 예외 처리는 일반적으로 AuthenticationFailHandler 에서 처리한다.
스프링 시큐리티는 예외 처리를 위한 필터가 따로 존재한다. 하지만 인증 과정에 따른 예외처리는 전용 예외 처리 필터에서 처리하는 것이 아니라 AuthenticationFailHandler 에서 예외처리를 한다.
Spring Security에는 인증 및 인가 실패를 처리하기 위한 필터(`ExceptionTranslationFilter`)가 존재한다. 하지만 로그인 등의 인증 과정에서 발생하는 실패는 해당 인증 필터(`UsernamePasswordAuthenticationFilter`) 내부에서 처리되며, 이때 `AuthenticationFailureHandler`를 통해 예외를 처리한다.
UsernamePasswordAuthenticationFilter 인증 처리 과정
AbstractAuthenticationProcessingFilter
AbstractAuthenticationProcessingFilter 의 doFilter 코드지만 구현체인 UsernamePasswordAuthenticationFilter 가 실행된다.
인증을 요청을 한 (POST /login) 유무를 확인하기 위해 requestMatcher 를 확인한다.
UsernamePasswordAuthenticationFilter 에 오버라이딩 된 attemptAuthentication() 를 통해 인증 객체를 생성한다.
요청 정보로부터 username 과 password를 얻어 온다. 그리고 이 두 정보를 통해 UsernamePasswordAuthenticationToken에 저장한다. 이때 아직 인증 전이기 때문에 unauthenticated() 메소드가 사용된다.
그리고 return 에서 AuthenticationManager에게 실제 인증 처리를 위임하고 있다.
여기까지 과정이 인증 성공, 실패 과정에서 동일하게 발생하는 과정이다. 이후 과정은 AuthenticationManager의 인증 처리 과정 true / false에 따라 각각 진행된다.
인증에 성공했기 때문에 UsernamePasswordAuthenticationToken에 principal 정보가 추가되었다. 이때 저장된 principal 정보는 config 에서 설정했던 정보들이다. (DB를 사용한다면 그 정보가 저장될 것이다.)
SessionAuthenticationStrategy 를 통해 새로운 로그인을 알리고 세션 관련 작업들을 수행한다
인증 상태 유지 를 위한 작업이 진행된다.
- 빈 SecurityConext 객체를 만들어 인증 정보 set
- SecurityConextHolder에 SecurityConext 정보 set
- 세션에 SecurityConext 를 저장하기 위해 securityContextRepository 의 구현체인 HttpSessionSecurityContextRepository 에서 세션에 인증 객체를 저장하고 있다.
이후 과정으로 아래 3과정을 진행하면서 마무리 된다.
- 자동 로그인과 같은 기능 유무를 설정하기 위해 RememberMeService.loginSuccess를 호출하여 Remember-me를 설정
인증 성공 이벤트 게시 ( ApplicationEventPulisher )
인증 성공 핸들러 호출 ( AuthenticationSuccessHandler )
UsernamePasswordAuthenticationFilter 인증 실패 과정
인증에 실패하게 되면 BadCredentialsException이 발생하면서 catch 로 진입한다.
인증이 실패하게 되면 아래 과정이 진행된다.
- SecurityConextHolder 삭제. 이전에 저장되어 있던 SecurityConext 가 있다면 삭제
- RememberMeService.loginFail 을 호출해서 자동 로그인 유무 삭제
- 인증 실패 핸들어 호출 ( AuthenticationFailHandler )
2. 기본 인증 - HttpBasic()
HTTP Basic 인증
- HTTP 는 액세스 제어와 인증을 위한 프레임워크를 제공하며 가장 일반적인 인증 방식은 "Basic" 인증 방식이다
- RFC 7235 표준이며 인증 프로토콜은 HTTP 인증 헤더에 기술되어 있다
- 클라이언트는 인증 정보 없이 서버로 접속을 시도한다
- 서버가 클라이언트에게 인증요구를 보낼 때 401 Unauthorized 응답과 함께 WWW-Authenticate 헤더를 기술해서 realm(보안영역) 과 Basic 인증방법을 보냄
- 클라이언트가 서버로 접속할 때 Base64 로 username 과 password 를 인코딩하고 Authorization 헤더에 담아서 요청함
- 성공적으로 완료되면 정상적인 상태 코드를 반환한다.
※ 참고
요즘은 HTTP Basic 인증은 잘 사용되진 않는다. 하지만 사용할 시 주의 사항을 준수해서 사용해야 한다.
httpBasic() API
- HttpBasicConfigurer 설정 클래스를 통해 여러 API 들을 설정할 수 있다
- 내부적으로 BasicAuthenticationFilter 가 생성되어 기본 인증 방식의 인증 처리를 담당하게 된다
HttpSecurity.httpBasic(httpSecurityHttpBasicConfigurer->httpSecurityHttpBasicConfigurer
.realmName("security") // HTTP 기본 영역을 설정한다
.authenticationEntryPoint(
(request,response,authException)->{}) //인증 실패 시 호출되는 AuthenticationEntryPoint 이다
//기본값은 "Realm"영역으로 BasicAuthenticationEntryPoint 가 사용된다
);
.realmName("security")
- HTTP 기본 영역을 설정한다
.authenticationEntryPoint((request,response,authException)->{})
- 인증 실패 시 호출되는 AuthenticationEntryPoint 이다
- 기본값은 "Realm"영역으로 BasicAuthenticationEntryPoint 가 사용된다
실습
SecurityConfig
Customizer.withDefaults() 를 통한 기본 동작 확인
package com.example.springsecuritymaster;
@Slf4j
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password("{noop}1111")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
http://localhost:8080/ 요청 시 인증 거부로 인한 401 을 응답받게 된다. 이후에 인증이 성공 처리되면 200 상태코드를 받을 것이다.
인증 이후의 Request Headers 를 확인하면 Authorization 헤더에 Basic 인코딩64 값 이 요청되는 것을 확인할 수 있다.
SecurityConfig - 수정
package com.example.springsecuritymaster;
@Slf4j
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(basic -> basic
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())); // 수정
return http.build();
}
}
CustomAuthenticationEntryPoint
package com.example.springsecuritymaster;
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.info("CustomAuthenticationEntryPoint");
response.setHeader("WWW-Authenticate", "Basic realm=security");
response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
}
}
http://localhost:8080/ 요청 시 WWW-Authenticate 헤더 값이 우리가 수정한 값으로 응답되는 것을 확인할 수 있다.
BasicAuthenticationFilter
- 이 필터는 기본 인증 서비스를 제공하는 데 사용된다
- BasicAuthenticationConverter 를 사용해서 요청 헤더에 기술된 인증 정보의 유효성을 체크하며 Base64 인코딩된 username 과 password 를 추출한다
- 요청 헤더에 기술된 인증정보의 유효성을 체크 : Authentication 헤더와 값이 Basic으로 시작하는지를 체크하는
- 위 유효성 체크 후 Base64 인코딩된 username 과 password 를 추출하여 인증 수행
- (※ 참고) 인증 이후 세션을 사용하는 경우와 사용하지 않는 경우(기본값) 에 따라 처리되는 흐름에 차이가 있다.
세션을 사용하는 경우 매 요청 마다 인증과정을 거치지 않으나 세션을 사용하지 않는 경우 매 요청마다 인증과정을 거쳐야 한다
흐름도
formLogin 흐름과 비슷하지만 약간의 차이가 있다. 차이점만 살짝 설명하겠다.
- SecurityContextHolder : 세션 사용 유무와 관계 없이 인증 정보를 SecurityContext에 설정한다.
- why? 필터 이후의 서블릿과 스프링 MVC에서 SecurityContext에 저장되어 있는 인증 정보에 접근하기 위해서 무조건 저장하는 것이다.
- 또한 SecurityContext는 세션이 아닌 요청 컨텍스트 에 저장되는데 이는 요청 범위 내에서만 인증 정보를 유지하기 위함이다. 즉, 요청 이후 응답이 되면 인증 상태가 사라진다. 그러므로 매번 인증 절차가 진행되는 것이다.
( form 인증은 세션 인증 정보 내에서 인증 정보가 유지. )
BasicAuthenticationFilter 인증 처리 과정
BasicAuthenticationConverter 를 사용해서 요청 헤더에 기술된 인증정보의 유효성을 체크
Header 값이 null 이므로 바로 chain.doFilter를 진행한다.
인증 이후에는 Base64 인코딩된 username 과 password 를 추출하고 UserPasswordAuthenticationToken을 반환한다.
이후에는 AuthenticationManager 를 통해 실제 인증을 진행하고 SecurityContext 에 인증 정보를 설정한다.
form 인증과의 차이는 인증 정보를 세션이 아닌 securityContextRepository 의 request 에 저장한다는 것이다.
3. 기억하기 인증 - RememberMe()
RememberMe 인증
- 사용자가 웹 사이트나 애플리케이션에 로그인할 때 자동으로 인증 정보를 기억하는 기능이다
- UsernamePasswordAuthenticationFilter 와 함께 사용되며, AbstractAuthenticationProcessingFilter 슈퍼클래스에서 훅을 통해 구현된다
- 인증 성공 시 RememberMeServices.loginSuccess() 를 통해 RememberMe 토큰을 생성하고 쿠키로 전달한다
- 인증 실패 시 RememberMeServices.loginFail() 를 통해 쿠키를 지운다
- LogoutFilter 와 연계해서 로그아웃 시 쿠키를 지운다
토큰 생성
기본적으로 암호화된 토큰으로 생성 되어지며 브라우저에 쿠키를 보내고, 향후 세션에서 이 쿠키를 감지하여 자동 로그인이 이루어지는 방식으로 달성된다
- base64(username + ":" + expirationTime + ":" + algorithmName + ":" algorithmHex(username + ":" + expirationTime + ":" password + ":" + key))
- username: UserDetailsService 로 식별 가능한 사용자 이름
- password: 검색된 UserDetails 에 일치하는 비밀번호
- expirationTime: remember-me 토큰이 만료되는 날짜와 시간, 밀리초로 표현
- key: remember-me 토큰의 수정을 방지하기 위한 개인 키
- algorithmName: remember-me 토큰 서명을 생성하고 검증하는 데 사용되는 알고리즘(기본적으로 SHA-256 알고리즘을 사용)
RememberMeServices 구현체
- TokenBasedRememberMeServices - 쿠키 기반 토큰의 보안을 위해 해싱을 사용한다 (메모리 방식)
- PersistentTokenBasedRememberMeServices -생성된 토큰을 저장하기 위해 데이터베이스나 다른 영구 저장 매체를 사용한다
- 두 구현 모두 사용자의 정보를 검색하기 위한 UserDetailsService 가 필요하다
rememberMe() API
- RememberMeConfigurer 설정 클래스를 통해 여러 API 들을 설정할 수 있다
- 내부적으로 RememberMeAuthenticationFilter 가 생성되어 자동 인증 처리를 담당하게 된다
http.rememberMe(httpSecurityRememberMeConfigurer -> httpSecurityRememberMeConfigurer
.alwaysRemember(true) // "기억하기(remember-me)" 매개변수가 설정되지 않았을 때에도 쿠키가 항상 생성되어야 하는지에 대한 여부를 나타낸다
.tokenValiditySeconds(3600) // 토큰이 유효한 시간(초 단위)을 지정할 수 있다
.userDetailsService(userDetailService) // UserDetails 를 조회하기 위해 사용되는 UserDetailsService를 지정한다
.rememberMeParameter("remember") // 로그인 시 사용자를 기억하기 위해 사용되는 HTTP 매개변수이며 기본값은 'remember-me' 이다
.rememberMeCookieName("remember") // 기억하기(remember-me) 인증을 위한 토큰을 저장하는 쿠키 이름이며 기본값은 'remember-me' 이다
.key("security") // 기억하기(remember-me) 인증을 위해 생성된 토큰을 식별하는 키를 설정한다
);
.alwaysRemember(true)
- "기억하기(remember-me)" 매개변수가 설정되지 않았을 때에도 쿠키가 항상 생성되어야 하는지에 대한 여부를 나타낸다 ( 기본값 false )
- 일반적으로 자동 로그인은 UI 에서 체크 박스를 클릭한 후 요청을 할 것이다. 하지만 이와는 별개로 alwaysRemember(true) 를 하게 되면 항상 체크 박스의 체크 유무와 관계 없이 자동 로그인이 자동 설정된다.
.tokenValiditySeconds(3600)
- 토큰이 유효한 시간(초 단위) 을 지정할 수 있다
.userDetailsService(userDetailService)
- UserDetails 를 조회하기 위해 사용되는 UserDetailsService를 지정한다
.rememberMeParameter("remember")
- 로그인 시 사용자를 기억하기 위해 사용되는 HTTP 매개변수이며 기본값은 'remember-me' 이다.
- 즉, 체크 박스의 이름이다.
.rememberMeCookieName("remember")
- 기억하기(remember-me) 인증을 위한 토큰을 저장하는 쿠키 이름이며 기본값은 'remember-me' 이다
.key("security")
- 기억하기(remember-me) 인증을 위해 생성된 토큰을 식별하는 키를 설정한다
실습
SecurityConfig 수정
package com.example.springsecuritymaster;
@Slf4j
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.rememberMe(rememberMe -> rememberMe
// .alwaysRemember(true)
.tokenValiditySeconds(3600)
.userDetailsService(userDetailsService())
.rememberMeParameter("remember")
.rememberMeCookieName("remember")
.key("security")
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password("{noop}1111")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
http://localhost:8080 으로 실행해보면 Spring Security 가 생성해준 체크 박스가 보일 것이다.
또한 Config에서 rememberMeParameter 의 값을 "remember" 로 변경해줬기 때문에 해당 체크 박스의 name이 변경되었다.
"기억하기"를 활성화하고 인증을 진행하면 브라우저는 "remember" 를 키로 하는 쿠키를 받을 것이다.
이제 어떻게 "기억하기" 기능을 확인할 수 있을까? 쿠키 중에서 JSESSIONID 를 지워보자.
세션 쿠기인 JSESSIONID 이 변경되거나 전송되지 않으면 해당 사용자가 이전에 왔던 사용자임을 인식하지 못하고 새로운 사용자로 인식해서 다시 인증 받아야한다.
JSESSIONID 을 지우고 요청을 보내면 "기억하기" 기능으로 인해 재 인증 절차가 없는 것을 확인할 수 있을 것이다.
또한 지웠던 세션 쿠키인 JSESSIONID 도 다시 받게 된다.
동일한 방식으로 "기억하기" 기능을 비활성화 하고 테스트를 해보면 매번 인증을 받아야 한다.
추가적으로 위 코드에서 주석 처리한 부분을 지우고 테스트를 해보면 "기억하기" 체크 유무와는 별개로 항상 "기억하기" 기능이 동작하는 것을 확인할 수 있을 것이다. 편리한? 기능이지만 위험한 기능이니 참고로만 알고 있자.
RememberMeAuthenticationFilter
- SecurityContextHolder에 Authentication이 포함되지 않은 경우 실행되는 필터이다. <= 이게 무슨 말일까??
- 우리는 폼 인증 또는 basic 인증을 통해 인증에 성공하게 되면 인증 성공을 처리하는 과정 속에서 반드시 SecurityContextHolder 안에 Authentication 을 저장하며 이를 session 혹은 요청 객체에 저장하는 과정이 포함되어 있었다.
- 이렇게 하는 이유는 인증 상태를 세션 범위 혹은 요청 범위 안에서 유지하기 위해서이다. 그래서 "인증이 유지된다" 는 말은 "이미 인증을 받았다는 뜻"이 된다.
- 여기서 "기억하기" 기능은 인증을 받지 않은 경우, 인증이 처리되지 않은 경우에 자동으로 인증을 해주기 위해 필요한 기능이지, 인증을 이미 받았으면 해당 기능을 필요가 없다.
- 이러한 이유로 Authentication 이 SecurityContextHolder 안에 있다는 의미는 "이미 현재 사용자는 인증된 상태로 존재해 있다"는 말이기 때문에 굳이 기억하기 인증을 통해서 자동 인증할 필요가 없다는 것이다.
- 그래서 자동 인증이 필요한 경우는 SecurityContextHolder에 Authentication이 포함되지 않은 경우 즉, 인증 상태가 아닌 경우에만 이 필터가 작동하겠다는 의미로 해석하면 된다.
- 세션이 만료되었거나 어플리케이션 종료로 인해 인증 상태가 소멸된 경우 토큰 기반 인증을 사용해 유효성을 검사하고 토큰이 검증되면 자동 로그인 처리를 수행한다.
- RememberMeAuthenticationFilter 에서 Authentication != null 유무를 검사하고 있다. 이때 Authentication 이 null 이면 인증 상태가 아니기 때문에 자동 로그인 처리를 하게 된다.
- RememberMeAuthenicationToken에 UserDetails + Authorities 정보를 담고 있다.
RememberMeAuthenticationFilter 인증 처리 과정
폼 인증 과정에서의 rememberMeService 처리
UsernamePasswordAuthenticationFilter 의 상위필터인 AbstractAuthenticationProcessingFilter에서 rememberMeService가 동작한다.
UsernamePasswordAuthenticationFilter 의 성공 과정은 앞에서 살펴봤으므로 필요한 부분만 살펴보자.
인증 성공 이후에 rememberMeServices.loginSuccess 처리를 하고 있다. 여기서 토큰을 만들고 클라이언트에게 쿠키로 전달할 것이다.
loginSuccess 에서 parameter를 검증하고 있는데, 우리가 설정했던 rememberMeName 파리미터 인지 검증하는 부분이다.
TokenBasedRememberMeServices.onLoginSuccess()
해당 메소드가 위에서 살펴봤던 토큰 생성 과정이다. 이렇게 생성한 토큰을 쿠키에 전달하고 있다.
위에서 생성한 토큰이 최종적으로 response.addCoodkie 로 사용자한테 전달되었다.
여기까지가 인증에 성공한 폼 인증 처리 과정 속에서 rememberMe 의 기억하기 인증을 위한 토큰을 생성해서 클라이언트에게 전달하는 과정이다.
이 작업 까지는 결국 폼 인증 필터에서 모두 처리되었다는 점에 주의하자.
이제 RememberMeAuthenticationFilter 처리 과정을 살펴보자.
RememberMeAuthenticationFilter.doFilter()
첫번째 포인트에 주목해보자.
첫 번째 조건이 true이면 바로 다음 필터로 doFilter 하기 때문에 "기억하기" 자동 인증이 동작하지 않는다. 이를 어떻게 false로 만들어 줄 수 있을까??
코드를 보면 현재 SecurityContext 안에서 Authenrtication 객체를 꺼내고 있다. 그리고 이를 null 로 만들어줘야 조건이 false 가 되면서 자동 인증을 수행할 것이다.
현재 인증 객체를 SecurityContext 안에 저장되어 있다. 그리고 우리는 폼 인증을 통해 인증을 처리했으므로 SecurityContext 는 세션에 저장되어 있을 것이다. 즉, 세션으로부터 SecurityContext 을 꺼내와서 인증 객체를 참조할 수 있다.
그런데 여기서 인증 객체가 null 이 되게 하려면 세션에서 해당 인증 객체를 찾지 못하게 만들면 가능할 것이다.
(아니...프레임워크가 내부적으로 세션에 인증 객체를 저장햇는데 우리가 어떻게 null 이 되게 할 수 있나...??)
세션 과정을 떠오려보자. 세션도 결국엔 클라인언트로부터 받게되는 쿠키 정보를 기반으로 세션에서 정보를 조회하는 것이다. 즉, 우리는 브라우저에서 JSESSIONID를 지워주면 된다!!!
자, 이제 JSESSIONID을 지우고 자동 인증 과정을 살펴보자.
이미 쿠키엔 remember 이란 쿠키가 있기 때문에 "기억하기" 자동 인증 과정이 동작할 것이다.
JSESSIONID 을 지워줬기 때문에 인증 객체가 null이다.
자동 인증에선 전달받은 쿠키를 통해서 username과 password 를 꺼내서 인증 과정을 진행해야 한다.
AbstractRememberMeServices.autoLogin()
해당 메소드에서 request로부터 쿠키를 가져오고 마지막엔 인증 객체인 RememberMeAuthenricatioToken 을 만들고 있다.
이렇게 생성한 인증 객체는 AuthenticationManger에게 전달되며 이후의 과정의 폼 인증 과정과 거의 동일하다.
4. 익명 인증 사용자 - Anonymous()
익명 사용자
- 스프링 시큐리티에서 "익명으로 인증된" 사용자와 인증되지 않은 사용자 간에 실제 개념적 차이는 없으며 단지 액세스 제어 속성을 구성하는 더 편리한 방법을 제공한다고 볼 수 있다.
- 즉, 스프링 시큐리티에서는 인증되지 않은 사용자를 "익명으로 인증된" 사용자 라고 표현한다는 것이다.
- SecurityContextHolder 가 항상 Authentication 객체를 포함하고 null 을 포함하지 않는다는 것을 규칙을 세우게 되면 클래스를 더 견고하게 작성할 수 있다.
- 인증 사용자와 익명 인증 사용자를 단순히 null 로 구분하는게 아닌 별도의 객체로써 관리하겠단 것이다.
- 인증 사용자와 익명 인증 사용자를 구분해서 어떤 기능을 수행하고자 할 때 유용할 수 있으며 익명 인증 객체는 세션에 저장하지 않는다. 즉, 세션에 저장하지 않는 다는 점만 차이가 있을 뿐 객체로써 접근하는 방식은 동일하다.
- 익명 인증 사용자의 권한을 별도로 운용할 수 있다. 즉, 인증 된 사용자가 접근할 수 없도록 구성이 가능하다
익명 사용자 API 및 구조
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests( auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.anonymous(anonymous -> anonymous
.principal("guest")
.authorities("ROLE_GUEST")
);
return http.build();
}
AnonymousAuthenticationFilter 에서 현재 접속한 사용자가 인증받지 못했으면 이 필터에서 익명 사용자의 인증 객체를 생성하고 Security Context 안에 저장하는 역할을 한다.
AnonymousAuthenticationToken이라는 익명 사용자용 토큰 객체를 만든다. "anonymousUser" 문자열의 이름 ( 이 이름이 principal 에 담긴다.) 과 ROLE_ANONYMOUS 권한이 Default 로 토큰에 저장된다. 위 코드처럼 커스텀 가능하다.
이런 구조는 인증 받은 사용자의 UsernamePasswordAuthenticationFilter에서의 UsernamePasswordAuthenticationToken생성으로 인한 인증 처리 과정과 동일하다.
즉, 인증된 사용자와 익명 사용자 간의 구분을 객체화 시켜서 더 편리하게 사용할 수 있다.
스프링 MVC 에서 익명 인증 사용하기
- 스프링 MVC가 HttpServletRequest#getPrincipal 을 사용하여 파라미터를 해결하는데 요청이 익명일 때 이 값은 null 이다
- 임의의 메소드가 있고 이 메소드를 클라이언트가 호출하고 있다. 이 메소드에는 Authentication 타입으로 파라미터가 정의되어 있다.
- if 조건문을 통해 authentication 이 AnonymousAuthentication 타입이면 "anonymous"를 반환, 그렇지 않으면 "not anonymous"를 반환하는 로직을 만들어두었다. 현재 코드 상에선 특별한 문제가 없어 보인다.
- 그리고 메소드 파마리터로 Authentication 을 선언해두면 인증 객체가 들어온다. 그리고 이 역할을 하는 것이 HttpServletRequest#getPrincipal 이다. 이 메소드가 내부적으로 동작하면서 이 매개변수한테 인증 받은 사용자의 인증 객체를 주거나 또는 인증 받지 못한 익명 사용자의 인증 객체를 전달해준다.
- 그런데 요청이 익명일 때는 Authentication authentication 에 AnonymousAuthenticationToken 타입이 들어오는 것이 아니라 null 이 들어온다는 것이다.
- 그래서 결국 "not anonymous" 를 반환한다.
public String method(Authentication authentication) {
if (authentication instanceof AnonymousAuthenticationToken) {
return "anonymous";
} else {
return "not anonymous";
}
}
- 익명 요청에서 Authentication 을 얻고 싶다면 @CurrentSecurityContext를 사용하면 된다.
- CurrentSecurityContextArgumentResolver 에서 요청을 가로채어 처리한다
public String method(@CurrentSecurityContext SecurityContext context){
return context.getAuthentication().getName();
}
AnonymousAuthenticationFilter
- SecurityContextHolder 에 Authentication 객체가 없을 경우(null 일 경우) 를 감지하고 필요한 경우 새로운 Authentication 객체로 채운다
AnonymousAuthenticationFilter 이전 Filter 들로 인해 인증 처리가 되지 않았다면 그때서야 해당 필터가 동작하면서 SecurityContextHolder 에 Authentication 객체를 설정한다.
실습
SecurityConfig 수정
package com.example.springsecuritymaster;
@Slf4j
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/anonymous").hasRole("GUEST")
.requestMatchers("/anonymousContext", "/authentication").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.anonymous(anonymous -> anonymous
.principal("guest")
.authorities("ROLE_GUEST")
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password("{noop}1111")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
.requestMatchers("/anonymous").hasRole("GUEST")
- 권한이 GUEST 이 사용자만 접근을 허용하도록 하였다.
.requestMatchers("/anonymousContext", "/authentication").permitAll()
- 익명 객체의 파라미터 주입 테스트 위해 정의했다.
IndexController 추가
@GetMapping("/anonymous")
public String anonymous() {
return "anonymous";
}
@GetMapping("/authentication")
public String authentication(Authentication authentication) {
if (authentication instanceof AnonymousAuthenticationToken) {
return "anonymous";
} else {
return "not anonymous";
}
}
@GetMapping("/anonymousContext")
public String anonymousContext(@CurrentSecurityContext SecurityContext context) {
return "anonymousContext : " + context.getAuthentication().getName();
}
실행
- http://localhost:8080/anonymous
- 인증 처리하지 않고 접근하면 정상 접근이 가능하다.
- 인증 후 접근하면 403 erorr 가 발생하면서 권한이 없다는 응답이 돌아온다.
- http://localhost:8080/authentication
- 인증 처리 되지 않고 접근하면 "not anonymous" 가 출력된다.
- 인증 처리 후 접근해도 "not anonymous" 이 출력된다.
- http://localhost:8080/anonymousContext
- 인증 처리 되지 않고 접근하면 " anonymousContext : guest" 가 출력된다.
- 인증 처리 후 접근하면 "anonymousContext : user" 가 출력된다.
5. 로그 아웃 - logout()
로그아웃
- 스프링 시큐리티는 기본적으로 DefaultLogoutPageGeneratingFilter 를 통해 로그아웃 페이지를 제공하며 "GET / logout" URL 로 접근이 가능하다.
- 로그아웃 실행은 기본적으로 "POST / logout" 으로만 가능하나 CSRF 기능을 비활성화 할 경우 혹은 RequestMatcher 를 사용할 경우 GET, PUT, DELETE 모두 가능하다
- 로그아웃 필터를 거치지 않고 스프링 MVC 에서 커스텀 하게 구현할 수 있다. 단, 로그인 페이지가 커스텀하게 생성될 경우 로그아웃 기능도 커스텀하게 구현해야 한다
logout() API
http.logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer
.logoutUrl("/logoutProc") // 로그아웃이 발생하는 URL 을 지정한다 (기본값은 “/logout” 이다)
.logoutRequestMatcher(new AntPathRequestMatcher("/logoutProc","POST")) // 로그아웃이 발생하는 RequestMatcher 을 지정한다. logoutUrl 보다 우선적이다
// Method 를 지정하지 않으면logout URL이 어떤 HTTP 메서드로든 요청될 때 로그아웃 할 수 있다
.logoutSuccessUrl("/logoutSuccess") // 로그아웃이 발생한 후 리다이렉션 될 URL이다. 기본값은 "/login?logout"이다
.logoutSuccessHandler((request, response, authentication) -> { // 사용할 LogoutSuccessHandler 를 설정합니다.
response.sendRedirect("/logoutSuccess"); // 이것이 지정되면 logoutSuccessUrl(String)은 무시된다
})
.deleteCookies("JSESSIONID", "CUSTOM_COOKIE") // 로그아웃 성공 시 제거될 쿠키의 이름을 지정할 수 있다
.invalidateHttpSession(true) // HttpSession을 무효화해야 하는 경우 true (기본값), 그렇지 않으면 false 이다
.clearAuthentication(true) // 로그아웃 시 SecurityContextLogoutHandler가 인증(Authentication)을 삭제 해야 하는지 여부를 명시한다
.addLogoutHandler((request, response, authentication) -> {}) // 기존의 로그아웃 핸들러 뒤에 새로운 LogoutHandler를 추가 한다
.permitAll() // logoutUrl(), RequestMatcher() 의 URL 에 대한 모든 사용자의 접근을 허용 함
.logoutUrl("/logoutProc")
- 로그아웃이 발생하는 URL 을 지정한다 (기본값은 "/logout" 이다)
.logoutRequestMatcher(new AntPathRequestMatcher("/logoutProc","POST"))
- 로그아웃이 발생하는 RequestMatcher 을 지정한다. logoutUrl 보다 우선적이다
- Method 를 지정하지 않으면 logout URL이 어떤 HTTP 메서드로든 요청될 때 로그아웃 할 수 있다
.logoutSuccessUrl("/logoutSuccess")
- 로그아웃이 발생한 후 리다이렉션 될 URL이다. 기본값은 "/login?logout" 이다
.logoutSuccessHandler((request, response, authentication) -> { response.sendRedirect("/logoutSuccess"); })
- 사용할 LogoutSuccessHandler 를 설정합니다.
- 이것이 지정되면 logoutSuccessUrl(String) 은 무시된다
.deleteCookies("JSESSIONID“, “CUSTOM_COOKIE”)
- 로그아웃 성공 시 제거될 쿠키의 이름을 지정할 수 있다
.invalidateHttpSession(true)
- HttpSession을 무효화해야 하는 경우 true (기본값), 그렇지 않으면 false 이다
.clearAuthentication(true)
- 로그아웃 시 SecurityContextLogoutHandler가 인증(Authentication)을 삭제 해야 하는지 여부를 명시한다
.addLogoutHandler((request, response, authentication) -> {})
- 기존의 로그아웃 핸들러 뒤에 새로운 LogoutHandler를 추가 한다
.permitAll()
- logoutUrl(), RequestMatcher() 의 URL 에 대한 모든 사용자의 접근을 허용 함
LogoutFilter
실습
SecurityConfig 수정
package com.example.springsecuritymaster;
@Slf4j
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.logout(logout -> logout
.logoutUrl("/logoutProc")
.logoutRequestMatcher(new AntPathRequestMatcher("/logoutProc", "POST"))
.logoutSuccessUrl("/logoutSuccess")
.logoutSuccessHandler(new LogoutSuccessHandler() {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.sendRedirect("/logoutSuccess");
}
})
.deleteCookies("JSESSIONID", "remember-me")
.invalidateHttpSession(true)
.clearAuthentication(true)
.addLogoutHandler(new LogoutHandler() {
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
HttpSession session = request.getSession();
session.invalidate();
SecurityContextHolder.getContextHolderStrategy().getContext().setAuthentication(null);
SecurityContextHolder.getContextHolderStrategy().clearContext();
}
})
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password("{noop}1111")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
실행 http://localhost:8080/logout
해당 url의 <form> action을 보면 /logout 으로 되어 있다. 우리는 이를 /logoutProc로 커스텀 했기 때문에 위 로그아웃 페이지는 동작을 안할 것이다.
그래서 http://localhost:8080/logoutProc 으로 접근하면?? 404 not found가 발생한다.
브라우저는 GET 방식으로 요청하기 때문에 우리가 정의한 POST 방식과는 맞지 않아 동작하지 않는 것이다.
이를 해결하기 위해서는 logoutRequestMatcher(new AntPathRequestMatcher("/logoutProc")) 와 같이 method를 지정하지 않으면 모든 종류의 메소드를 허용하기 때문에 정상 동작할 것이다.
logoutSuccessUrl("/logoutSuccess") 테스트 - SecurityConfig 수정
@Slf4j
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/logoutSuccess").permitAll() // 추가
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.logout(logout -> logout
.logoutUrl("/logout") // 수정
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "POST")) // 수정
...
return http.build();
}
...
}
IndexController 추가
@GetMapping("/logoutSuccess")
public String logoutSuccess() {
return "logoutSuccess";
}
LogoutFilter 인증 처리 과정
우선 요청이 logoutRequestMatcher 와 일치하는지 여부를 검사한다.
SecurityContext 로부터 인증 객체를 가져온다. 현재 인증 객체는 UsernamePasswordAuthenticationToken 이다.
이후에 LogoutHandler 와 SuccessHandler를 실행한다.
이때 logoutHandler를 보면 CompositeLogoutHandler 인것을 확인할 수 있다. 현재 총 5개의 LogoutHandler 를 갖고 있으면 이 중 2번 째 SecurityConfig의 LogoutHandler 가 우리가 정의한 LogoutHandler 이다. 나머지 4개는 스프링이 만들어둔 LogoutHandler 이다.
6. 요청 캐시 - RequestCache / SavedRequest
RequestCache
- 인증 절차 문제로 리다이렉트 된 후에 이전에 했던 요청 정보를 담고 있는 "SavedRequest" 객체를 쿠키 혹은 세션에 저장하고 필요시 다시 가져와 실행하는 캐시 메카니즘 이다
SavedRequest
- SavedRequest 은 로그인과 같은 인증 절차 후 사용자를 인증 이전의 원래 페이지로 안내하며 이전 요청과 관련된 여러 정보를 저장한다
흐름도
- 인증 받지 않은 상태로 접근을 시도한다.
- 인증을 받기 전에 HttpSessionRequestCache 객체의 saveRequest() 메소드를 통해 DefaultSavedRequest 를 만들어 HttpSession 에 저장한다. 이때 DefaultSavedRequest 에는 인증 받지 않은 상태에서 접근하고자 했던 요청 정보 등을 저장한다.
- 이후 Redirect 를 통해 인증 절차로 보낸다.
- 인증이 성공 했다고 가정
- 인증에 성공했으므로 AuthenticationSuccessHandler 를 통해 어떤 작업을 처리한다.
- 이때 어떤 작업이란? HttpSessionRequestCache 에 저장된 DefaultSavedRequest 를 가져와서 getRedirectUrl() 을 호출하여 인증 이전에 가고자 했던 요청 경로를 가져와서 Redirect 한다.
requestCache() API
- 요청 Url 의 쿼리스트링에 customParam=y 라는 이름의 매개 변수가 있는 경우에만 HttpSession 에 저장된 SavedRequest 을 꺼내오도록 설정할 수 있다 (기본값은 "continue" 이다)
HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
requestCache.setMatchingRequestParameterName("customParam=y");
http
.requestCache((cache)->cache
.requestCache(requestCache)
);
- 요청을 저장하지 않도록하려면 NullRequestCache 구현을 사용할 수 있다
RequestCache nullRequestCache = new NullRequestCache();
http
.requestCache((cache)->cache
.requestCache(nullRequestCache)
);
RequestCacheAwareFilter
- RequestCacheAwareFilter 는 이전에 저장했던 웹 요청(SavedRequest) 을 다시 불러오는 역할을 한다
- SavedRequest 가 현재 Request 와 일치하면 이 요청을 필터 체인의 doFilter 메소드에 전달하고 SavedRequest 가 없으면 필터는 원래 Request 을 그대로 진행시킨다
RequestCache / SavedRequest 처리 과정
위 흐름도에서 살펴봤던 "인증 받지 않은 상태로 접근을 시도" 일 경우 동작을 우선 살펴보자.
현재 http://localhost:8080/home 에 요청을 하고 디버깅 중이다.
ExceptionTranslationFilter.sendStartAuthentication()
- ExceptionTranslationFilter 는 스프링 시큐리티에서 인증/ 인가 예외가 발생했을 경우 해당 필터에서 예외 처리를 하는 필터이다. (추후 학습)
sendStartAuthentication() 메소드에서 requestCache의 saveRequest() 메소드에게 request, response를 전달하고 있다.
HttpSessionRequestCache.saveRequest()
saveRequest() 메소드에선 DefaultSavedRequest 객체를 생성하고 있으며 생성자에 현재 파라미터의 request와 this.matchingRequestParameterName 이라는 필드를 전달해주고 있다.
여기서 matchingRequestParameterName 의 값은 "continue" 이다.
또한 이렇게 생성된 DefaultSavedRequest 객체는 세션에 저장되고 있다.
여기까지가 "인증 받지 않은 상태로 접근을 시도" 했을 경우 이다.
그리고 인증 인증 절차를 거친 이후...
현재 폼 인증 방식으로 인증에 성공했고 인증 성공 이후에 SuccessHandler 에서 마지막 인증 후속 작업이 진행된다.
SavedRequestAwareAuthenticationSuccessHandler.onAuthenticationSuccess()
해당 메소드를 살펴보면 requestCache에서 savedRequest 를 가져오고 있다. 이때 requestCache는 HttpSessionRequestCache인 것을 확인할 수 있다.
requestCache의 getRequest() 에서는 세션에서 savedRequest 를 가져온다.
이렇게 가져온 savedRequest 에는 이전 요청 Url 등 여러 정보가 포함되어 있으며 최종적으로 /home으로 redirect 한다.
실습
SecurityConfig 수정
위에서 살펴봤던 과정을 직접 커스텀하면 아래 코드와 같이 할 수 있다.
package com.example.springsecuritymaster;
@Slf4j
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/logoutSuccess").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
SavedRequest savedRequest = requestCache.getRequest(request, response);
String redirectUrl = savedRequest.getRedirectUrl();
response.sendRedirect(redirectUrl);
}
})
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withUsername("user")
.password("{noop}1111")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
RequestCacheAwareFilter 동작 과정
여기까지 살펴보고 드는 의문점은 http://localhost:8080/home?continue 와 같이 URL에 "continue" 값이 붙는 다는 것이다.
그렇다면 " continue"는 왜 붙는 것이며 어떤 용도로 사용되는 것일까??
해답을 찾기 위해선 RequestCacheAwareFilter에 대해 살펴볼 필요가 있다.
RequestCacheAwareFilter.doFilter()
우선 해당 필터는 모든 요청에 대해서 다 받게 되어 있다. 즉, 요든 요청에 대해서 savedRequest 를 가져오는 작업을 하고 있는 것이다.
그런데 단지 그냥 가져는 것이 아닌 메소드 명에서 보이는 것처럼 getMatchingRequest() 어떤 것과 매칭되는 것만 가져오고 있다.
HttpSessionRequestCache.getMatchingRequest()
해당 메소드에서 조건에 맞는 경우 saveRequest 객체를 가져올 것으로 예상된다.
우선 첫 요청인 http://localhost:8080/home 에는 "continue" 값이 없기 때문에 아래 보이는 두번째 조건문에서 true를 만족하면서 return null 을 할것이다.
인증 성공 이후에는 http://localhost:8080/home?continue 와 같이 쿼리 스트링이 붙은 채로 해당 필터로 다시 진입할것이다.
그렇게 되면 request.getQueryString() 이 존재하므로 두번째 조건문이 false 를 만족하면서 그제서야 실제 세션으로부터 savedRequest 객체를 가져오게 된다.
그리고는 matchesSavedRequest() 를 통해 현재 Request 와 savedRequest 를 비교한다. 해당 메소드를 살펴보면 단순 비교가 아닌 많은 조건을 통해 비교 작업이 이뤄지고 있는 것을 확인할 수 있을 것이다.
그렇게 비교가 정상 완료되면 현재 Request 를 제거하고 SavedRequest로 감싼 클래스를 반환하면서 그 다음 필터에 넘겨주는 작업을 한다.