스프링에서는 SecurityFilterChain에서 사용할 기본 Filter들에 대해서 사전에 등록해두었다
FilterOrderRegistration.class
FilterOrderRegistration() {
put(DisableEncodeUrlFilter.class, order.next());
put(ForceEagerSessionCreationFilter.class, order.next());
put(ChannelProcessingFilter.class, order.next());
order.next(); // gh-8105
put(WebAsyncManagerIntegrationFilter.class, order.next());
put(SecurityContextHolderFilter.class, order.next());
put(SecurityContextPersistenceFilter.class, order.next());
put(HeaderWriterFilter.class, order.next());
put(CorsFilter.class, order.next());
put(CsrfFilter.class, order.next());
put(LogoutFilter.class, order.next());
...
put(UsernamePasswordAuthenticationFilter.class, order.next());
...
put(DefaultLoginPageGeneratingFilter.class, order.next());
put(DefaultLogoutPageGeneratingFilter.class, order.next());
...
}
위클래스를보면 정말많은 Filter들이 등록되는걸 알 수 있다
아무것도 설정하지 않고 실행해보면 위와같이 총 14개의 기본Filter가등록된다
이렇게 많은 필터중 중요한 부분들에 대해서만 알아보자
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
...
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
...
try {
SecurityContextHolder.setContext(contextBeforeChainExecution);
...
chain.doFilter(holder.getRequest(), holder.getResponse());
...
}
finally {
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
SecurityContextHolder.clearContext();
this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
...
}
}
Filter가 진행되는 동안 SecurityContext에 접근할 수 있게 해주며 필요하다면 Session까지도 강제로 등록할 수 있다 ( 세션등록은 많은 리소스를 사용하고 권장하지 않으므로 기본값은 false이다 )
그리고 필터라 종료될 시 Clear를 한 뒤 해당 Context를 따로 repository에 저장하는걸 볼 수 있다 ( HttpSessionSecurityContextRepository )
추 후 Session이나 AutuLogin을 구현하기 위해 염두해둔것
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
if (!isValid || CorsUtils.isPreFlightRequest(request)) {
return;
}
filterChain.doFilter(request, response);
}
Cors정책에 대해 유효한지 확인해주는 필터이다
CorsConfiguration의 정보를 토대로 process를 한 뒤 Valid하지 않거나 PreFlightReques인 경우 연결을 차단한다
Csrf는 해킹공격 기법으로 쉽게말해 A사이트에 인증된 사용자를 특정 이벤트를 발생시 해커사이트로 이동 후 해커사이트에서 A사이트에 해당 사용자의 정보로 데이터를 임의로 조작하는 행위
즉 서버는 브라우저에 저장된 세션값을 신뢰하기에 해당 브라우저를 통해서만 보내면 같은 유저라고 믿는걸 악용한 점이다
해당문제는 Csrf토큰을 가져와 요청시 해당 토큰을 보내주어 처리 ( 물론 XSS와같은 방식을 이용해 Cookie를 꺼내간다거나 세션에 접근하는 등의 문제가 생기면 결국 이야기는 원점으로 돌아감 )
토큰은 서버 사이드 스크립트를 통해 내려주거나 따로 API 엔드포인트를 만들어 CSRF토큰을 가져가게 하는 방법이 있다
이제 CsrfFilter에 관해 살펴보자
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
...
// 쿠키 or 세션에서 가져온다
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
boolean missingToken = (csrfToken == null);
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
...
this.accessDeniedHandler.handle(request, response, exception);
return;
}
filterChain.doFilter(request, response);
}
위에서 다 설명한 내용을 그대로 코드로 구현한 것 이다
세션이나 쿠키를통해 Csrf Token을 가져오고( 변경되지 않았다고 브라우저를 신뢰 ), 요청의 헤더나 파라미터로 Csrf Token을 받아오면 이 둘을 비교하여 같다면 같은 사용자로 판단하는 것 이다
다를시 accessDeniedHandler.handle
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) {
if (requiresLogout(request, response)) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
...
this.handler.logout(request, response, auth);
this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
return;
}
chain.doFilter(request, response);
}
RequestMatcher가 /logout이라면 LogoutHandler에게 처리를 위임시키고 logoutSuccessHandler에게 성공적으로 처리했다고 위임한다
만약 아니라면 필터진행
LogoutHandler : Composite패턴으로 이루어져 있으며 remember-me쿠키삭제, Csrf토큰 삭제, 쿠키 초기화 등의 Handler들이 있다
X509인증서를 이용할시 사용되는 Filter로 별다른 기능은없고 Authentication에 X509인증서 객체를 추가한다
private X509Certificate extractClientCertificate(HttpServletRequest request) {
...
X509Certificate[] certs = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate");
...
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
...
String username = obtainUsername(request);
...
String password = obtainPassword(request);
...
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
...
setDetails(request, authRequest); // SessionId, remoteAddress ...
return this.getAuthenticationManager().authenticate(authRequest);
}
해당 클래스는 Authentication객체만 가져온다 실제 처리는 이의 상위인 AbstractAuthenticationProcessingFilter가 처리한다
AbstractAuthenticationProcesingFilter.class
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
...
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
...
chain.doFilter(request, response);
successfulAuthentication(request, response, chain, authenticationResult);
}
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
// remeber-me token, cookie
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
// event publish
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
this.successHandler.onAuthenticationSuccess(request, response, authResult); // redirect or foward
}
RequestMatcher에 해당하지 않는다면 바로 넘어가고 만약 해당 Request가 인증이 필요하다면 인증정보를 불러오고 없으면 바로 종료한다
이 후 세션에 인증정보를 등록하고 sucessfulAuthentication메서드를 통해 Context를 초기화 다음세션을위해 Context저장, remeberMeServices를 통해 remeber-me토큰 발급 및 저장, 로그인 성공 이벤트, SucessHandler에 따라 redirect나 foward를 진행한다
세션을 항상 최신 날짜로 갱신시키거나, 만료된지 만약 만료됐다면 로그아웃과 같은 세션의 상태와 관련된 작업을 처리한다
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpSession session = request.getSession(false);
if (session != null) {
SessionInformation info = this.sessionRegistry.getSessionInformation(session.getId());
if (info != null) {
if (info.isExpired()) {
...
doLogout(request, response);
...
this.sessionInformationExpiredStrategy.onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response));
return;
}
this.sessionRegistry.refreshLastRequest(info.getSessionId());
}
}
}
Digest암호화 방식을 사용할때의 인증처리를 진행하는 필터다
( 대표적인 Digest암호화방식 : MD5, SHA )
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Digest ")) {
chain.doFilter(request, response);
return;
}
DigestData digestAuth = new DigestData(header);
...
digestAuth.validateAndDecode(this.authenticationEntryPoint.getKey(), this.authenticationEntryPoint.getRealmName());
...
UserDetails user = this.userCache.getUserFromCache(digestAuth.getUsername());
...
try {
if (user == null) {
...
user = this.userDetailsService.loadUserByUsername(digestAuth.getUsername());
this.userCache.putUserInCache(user);
}
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
// 서버에서 가져온 UserPW의 MD5값과 요청으로 받아온 MD5값 비교
if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) {
//다르므로 한번더 검증
user = this.userDetailsService.loadUserByUsername(digestAuth.getUsername());
this.userCache.putUserInCache(user);
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
}
}
catch (UsernameNotFoundException ex) {
String message = this.messages.getMessage("DigestAuthenticationFilter.usernameNotFound",new Object[] { digestAuth.getUsername() }, "Username {0} not found");
fail(request, response, new BadCredentialsException(message));
return;
}
...
if (digestAuth.isNonceExpired()) {
...
}
Authentication authentication = createSuccessfulAuthentication(request, user);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
chain.doFilter(request, response);
}
요청으로 받아온 MD5값과 서버에서 가져온 데이터를 MD5로 Digest한 후 이값을 비교한다
그 후 nonce값이 유효한지 판단 후 통과하면 Authentication을 등록한다
Basic Http인증 스킴을 사용하는 방식으로 Base64로 인코딩해 Header에 실어 나른다
( Base64는 복호화가 되므로 SSL/TLS를 함께 사용해야 함 )
BasicAuthenticationConverter.class
@Override
public UsernamePasswordAuthenticationToken convert(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
...
if (!StringUtils.startsWithIgnoreCase(header, AUTHENTICATION_SCHEME_BASIC)) {
return null;
}
byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
byte[] decoded = decode(base64Token);
String token = new String(decoded, getCredentialsCharset(request));
int delim = token.indexOf(":");
UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken.unauthenticated(token.substring(0, delim), token.substring(delim + 1));
result.setDetails(this.authenticationDetailsSource.buildDetails(request));
return result;
}
convert메서드는 request를 읽어와 Authorization헤더에서 값을 가져오고 Basic scheme로 시작하는지 확인하는 작업을 거친다
이 작업을 완수하게되면 base64Token을 불러와 decoding을 진행하고 나온 결과값을 : 값기준으로 왼쪽은 ID, 오른쪽은 PW값으로 매핑시킨다
BasicAuthenticationFilter.class
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request);
if (authRequest == null) {
chain.doFilter(request, response);
return;
}
...
Authentication authResult = this.authenticationManager.authenticate(authRequest);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
this.rememberMeServices.loginSuccess(request, response, authResult);
this.securityContextRepository.saveContext(context, request, response);
onSuccessfulAuthentication(request, response, authResult);
}
UsernamePasswordAuthenticationFilter와 마찬가지로 Convert를 통해 Token을 받아와 존재하면 Context에 저장 및 Authentication등록 loginSucess등의 작업을 진행한다
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest wrappedSavedRequest = this.requestCache.getMatchingRequest((HttpServletRequest) request,(HttpServletResponse) response);
chain.doFilter((wrappedSavedRequest != null) ? wrappedSavedRequest : request, response);
}
요청을 Caching해 Cookie나 Session에 값을저장하고 응답을준다
이 후 해당 Cookie나 Session을 통해 해당 값이 들어오고 해당 값이 유효하면 이전에 캐싱해뒀던 요청값으로 대체해 전달한다
HttpSessionRequestCache.class
static final String SAVED_REQUEST = "SPRING_SECURITY_SAVED_REQUEST";
private String sessionAttrName = SAVED_REQUEST;
@Override
public void saveRequest(HttpServletRequest request, HttpServletResponse response) {
...
if (this.createSessionAllowed || request.getSession(false) != null) {
request.getSession().setAttribute(this.sessionAttrName, savedRequest);
}
...
}
Session을 이용한 저장방식
CookieRequestCache.class
private static final String COOKIE_NAME = "REDIRECT_URI";
@Override
public void saveRequest(HttpServletRequest request, HttpServletResponse response) {
String redirectUrl = UrlUtils.buildFullRequestUrl(request);
Cookie savedCookie = new Cookie(COOKIE_NAME, encodeCookie(redirectUrl));
...
savedCookie.setPath(getCookiePath(request));
...
response.addCookie(savedCookie);
}
Cookie를 이용한 저장방식
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
chain.doFilter(this.requestFactory.create((HttpServletRequest) req, (HttpServletResponse) res), res);
}
HttpServletRequest, Response로 Wrapping해준다
AbstractRememberMeServices.class
public static final String SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me";
@Override
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
...
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
...
return createSuccessfulAuthentication(request, user)
}
protected Authentication createSuccessfulAuthentication(HttpServletRequest request, UserDetails user) {
RememberMeAuthenticationToken auth = new RememberMeAuthenticationToken(this.key, user,
this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
auth.setDetails(this.authenticationDetailsSource.buildDetails(request));
return auth;
}
remember-me 토큰이 존재할경우 해당 Token값을 토대로 RememberMeAuthentication을 발급한다
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (SecurityContextHolder.getContext().getAuthentication() != null) {
chain.doFilter(request, response);
return;
}
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(rememberMeAuth);
SecurityContextHolder.setContext(context);
onSuccessfulAuthentication(request, response, rememberMeAuth);
this.securityContextRepository.saveContext(context, request, response);
...
}
...
}
위에서의 autoLogin을통해 Authentication을 발급받고 이를 토대로 작업을 한다
이 후 이제는 익숙한 인증성공시 반복적으로 처리해주느 작업을 진행한다
Context생성 및 Authentication등록, Context를 Holder에등록, 추 후 Session관리를 위해 ContextRepository에 등록하는 작업 등이 계속 반복적으로 나온다
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
if (SecurityContextHolder.getContext().getAuthentication() == null) {
Authentication authentication = createAuthentication((HttpServletRequest) req);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
...
}
...
}
protected Authentication createAuthentication(HttpServletRequest request) {
AnonymousAuthenticationToken token = new AnonymousAuthenticationToken(this.key, this.principal, this.authorities);
token.setDetails(this.authenticationDetailsSource.buildDetails(request));
return token;
}
여태까지 Filter를 지나쳐왔음에도 Context가없으면 이증처리가 되지않은것이므로 AnonymousAuthenticationToken을 발급해준다
이 후 성공처리를 진행하는데 익명인증같은경우 구지 세션까지 처리할필요가 없기에 저장하는 부분은 생략된걸 볼 수 있다
위에 ConcurrentSesionFilter는 세션의 상태에 따라 만료됐을때의 처리작업이나 날짜 상태를 최신으로 갱신하는 작업을 했다면
SessionManagementFilter는 다중로그인이나 세션ID값을 계속 바꿔 주는 작업을 진행한다
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
...
if (!this.securityContextRepository.containsContext(request)) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && !this.trustResolver.isAnonymous(authentication)) {
...
this.sessionAuthenticationStrategy.onAuthentication(authentication, request, response);
...
}
}
}
SessionAuthenticationStrategy는 다양한 전략이 있다
필터체인 내에 발생하는 AccessDeniedException(권한부족, 익명사용자인경우 인증실패로 처리), AuthenticationException(인증실패)을 처리한다
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try{
...
}
catch(...) {
...
handleSpringSecurityException(request, response, chain, securityException);
}
}
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, RuntimeException exception) throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
}
else if (exception instanceof AccessDeniedException) {
handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
}
}
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
SecurityContext context = SecurityContextHolder.createEmptyContext();
SecurityContextHolder.setContext(context);
this.requestCache.saveRequest(request, response);
this.authenticationEntryPoint.commence(request, response, reason);
}
AuthenticationException인경우 단순하게 response에 RuntimeException exception을 담은뒤 AuthenticationEntryPoint로 추가작업을 진행한다
AuthenticationEntryPoint ( Composite패턴으로 여러 처리가 가능 )
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
...
sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException( this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
this.accessDeniedHandler.handle(request, response, exception);
}
}
AccessDeniedException 같은경우 익명유저일경우 AuthenticationException처리로 넘기고 사용자일경우 AccessDeniedHandler로 처리한다
AccessDeniedHandler
URL에 대해 액세스 권한을 부여하는 필터이다
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {
AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
if (decision != null && !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
filterChain.doFilter(request, response);
}
AuthorizationDecision전략에 따라 처리 후 승인되면 Pass아니면 AccessDeniedException을 발생시킨다
AuthorizationDecision전략