Spring Security
📌 보안이 고려되지 않았을 때
로그인(인증, Authentication) 기능이 없음
어떤 서비스를 이용할 때 ID/패스워드같은 인증 정보를 사용하여 자격을 증명하는 로그인 기능이 없다면, 나 자신을 증명할 방법이 없기 때문에 다른 사람이 회원으로 등록한 내 정보 또는 내가 주문한 주문 정보 등에 대해서 애플리케이션의 API를 호출하여 얼마든지 조회가 가능해진다.
API에 대한 권한 부여(인가, Authorization) 기능이 없음
애플리케이션의 서비스를 사용하기 위한 적절한 인증 절차를 거쳤다고 하더라도 모든 리소스에 접근할 수 있는 것은 아니다. 단순히 커피를 주문하는 회원의 경우 커피 목록에서 커피를 조회해서 주문하고자 하는 커피를 선택한 후, 주문을 할 것이다. 여기서 손님이 매장에서 판매하는 커피 정보를 마음대로 등록할 수 없어야 한다. 그런데 API에 대한 권한 부여 기능이 없다면 손님은 마음대로 커피 정보를 등록할 수 있을 것이다.
웹 보안 취약점에 대한 대비가 전혀 이루어지지 않음
웹 애플리케이션을 위협하는 세션 고정 공격, 클릭재킹 공격, CSRF 등의 보안 취약점에 대한 고려가 전혀 이루어지지 않는다.
📌 Spring Security
Spring Security는 Spring MVC 기반 애플리케이션의 인증(Authentication)과 인가(Authorization or 권한 부여) 기능을 지원하는 보안 프레임워크로써, Spring MVC 기반 애플리케이션에 보안을 적용하기 위한 사실상의 표준이다.
Spring Security로 할 수 있는 보안 강화 기능
- 다양한 유형(폼 로그인 인증, 토큰 기반 인증, OAuth 2 기반 인증, LDAP 인증)의 사용자 인증 기능 적용
- 애플리케이션 사용자의 역할(Role)에 따른 권한 레벨 적용
- 애플리케이션에서 제공하는 리소스에 대한 접근 제어
- 민감한 정보에 대한 데이터 암호화
- SSL 적용
- 일반적으로 알려진 웹 보안 공격 차단
- SSO 적용
- 클라이언트 인증서 기반 인증
- 메서드 보안
- 접근 제어 목록(Access Controll List)
Spring Security에서 사용하는 용어
- Principal(주체)
Principal은 애플리케이션에서 작업을 수행할 수 있는 사용자, 디바이스 또는 시스템 등을 말한다.
일반적으로 인증 프로세스가 성공적으로 수행된 사용자의 계정 정보를 의미한다. - Authentication(인증)
Authentication은 애플리케이션을 사용하는 사용자가 본인이 맞음을 증명하는 절차를 의미한다.
주민센터에 방문해서 주민등록등본을 발급받을 때를 생각해보자. 주민센터 직원이 여러분의 신원을 확인하기 위해 요청하는 여러분의 주민등록증이 바로 Credential이 될 수 있다. Authentication을 정상적으로 수행하기 위해서는 사용자를 식별하기 위한 정보가 필요한데 이를 Credential(신원 증명 정보)이라고 한다.
특정 사이트에서 로그인을 위해 입력하는 패스워드 역시 로그인 아이디를 증명하기 위한 Credential이 된다. - Authorization(인가 또는 권한 부여)
Authentication이 정상적으로 수행된 사용자에게 하나 이상의 권한(authority)을 부여하여 특정 애플리케이션의 특정 리소스에 접근할 수 있게 허가하는 과정을 의미한다.
Authorization은 반드시 Authentication 과정 이후 수행되어야 하며 권한은 일반적으로 역할(Role) 형태로 부여된다. - Access Control(접근 제어)
사용자가 애플리케이션의 리소스에 접근하는 행위를 제어하는 것을 의미한다.
Spring Security는 인증과 권한에 대한 부분을 Filter 흐름에 따라 처리한다. 자바의 서블릿 기반 애플리케이션의 경우 엔드포인트 요청이 도달하기 전에 중간에서 요청을 가로챈 후 어떤 처리를 할 수 있는 적절한 포인트를 제공하는데 그것이 바로 서블릿 필터(Servlet Filter)다.
서블릿 필터는 자바에서 제공하는 API이며, javax.servlet 패키지에 인터페이스 형태로 정의되어 있다. 인터페이스를 구현한 서블릿 필터들은 DispatcherServlet을 거치기 전 웹 요청(request)를 가로채어(intercept) 어떤 처리(전처리)를 할 수 있으며, 엔드포인트에서 요청 처리가 끝난 후 전달되는 응답(response)을 클라이언트에게 전달하기 전에 또 처리할 수 있다.
Spring Security는 기존의 서블릿 필터 사이에 Spring Security Filter 영역을 연결하여 스프링 시큐리티만의 보안, 인증, 예외 처리에 대한 작업을 수행한다. Spring Security Filter 영역은 DelegatingFilterProxy와 FilterChain Proxy로 구성되어 있다.
doFilter()
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// do something before the rest of the application
chain.doFilter(request, response); // invoke the rest of the application
// do something after the rest of the application
}
서블릿 필터 체인 영역의 모든 Filter Chain들은 Filter 인터페이스의 doFilter 메서드를 구현해 다음 Filter로 이동한다. Spring Security 역시 동일하게 doFilter 메서드를 통해 필터 이동을 한다.
DelegatingFilterProxy
Delegating(위임하는)이라는 이름의 의미에서 알 수 있듯이 Spring Security에서 보안과 관련된 어떤 작얼을 실제적으로 처리하는 것이 아니라 해당 역할을 Spring Security Filter에 위임해주는 역할을 한다. 서블릿 필터 체인의 필터와 애플리케이션에서 빈으로 등록된 필터들을 연결해주는 브릿지 역할을 한다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Lazily initialize the delegate if necessary.
Filter delegateToUse = this.delegate;
if (delegateToUse == null) {
synchronized (this.delegateMonitor) {
delegateToUse = this.delegate;
if (delegateToUse == null) {
WebApplicationContext wac = findWebApplicationContext();
if (wac == null) {
throw new IllegalStateException("No WebApplicationContext found: " +
"no ContextLoaderListener or DispatcherServlet registered?");
}
delegateToUse = initDelegate(wac);
}
this.delegate = delegateToUse;
}
}
// Let the delegate perform the actual doFilter operation.
invokeDelegate(delegateToUse, request, response, filterChain);
}
DelegatingFilterProxy.java 파일을 뜯어보면 역시나 필터이기 때문에 doFilter() 메서드를 구현하고 있는 것을 볼 수 있다.
findWebApplicationContext() 메서드 이름에서 알 수 있듯이 프로젝트 내에서 ApplicationContext를 찾는 것을 알 수 있다. 해당 과정을 통해 Spring Application Context에 빈으로 등록된 필터들을 사용한다.
FilterChainProxy
FilterChainProxy는 Spring Security에서 보안을 위한 실제적인 작업을 처리하는 필터들의 모음이다. Spring Security의 Filter를 사용하기 위한 진입점이 바로 FilterChainProxy이다. Filter Chain이 있을 때 어떤 Filter Chain을 사용할 지는 FilterChainProxy가 결정하며, 가장 먼저 매칭된 Filter Chain을 실행한다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
if (!clearContext) {
doFilterInternal(request, response, chain);
return;
}
try {
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
doFilterInternal(request, response, chain);
}
catch (RequestRejectedException ex) {
this.requestRejectedHandler.handle((HttpServletRequest) request, (HttpServletResponse) response, ex);
}
finally {
SecurityContextHolder.clearContext();
request.removeAttribute(FILTER_APPLIED);
}
}
private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest) request);
HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse) response);
List<Filter> filters = getFilters(firewallRequest);
if (filters == null || filters.size() == 0) {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest)));
}
firewallRequest.reset();
chain.doFilter(firewallRequest, firewallResponse);
return;
}
if (logger.isDebugEnabled()) {
logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest)));
}
VirtualFilterChain virtualFilterChain = new VirtualFilterChain(firewallRequest, chain, filters);
virtualFilterChain.doFilter(firewallRequest, firewallResponse);
}
FilterChainProxy.java 역시 doFilter() 메서드를 구현하고 있다.
doFilterInternal 메서드에서 filter의 여부나 갯수를 조회해 filter가 존재한다면 가상 체인 영역(VirtualFilterChain)을 생성해 해당 클래스의 doFilter로 가상 체인 영역의 다음 필터로 넘겨주는 것을 볼 수 있다. (인터셉트)
예외 발생
FilterSecurityInterceptor가 AccessDecisionManager를 사용하여 실제 인증과 인가를 처리한다.
여기서 예외가 발생하는 경우 ExceptionTranslationFilter에 전달되는데 ExceptionTranslationFilter는 인증과 인가에 대한 예외 처리를 핸들링 한다.
ExceptionTranslationFilter의 doFilter() 메서드
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response); // FilterSecurityInterceptor 필터 실행
}
catch (IOException ex) { // 예외 발생 1(IOException)
throw ex;
}
catch (Exception ex) { // 예외 발생 2
// Try to extract a SpringSecurityException from the stacktrace
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
// AuthenticationException인지 확인
RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (securityException == null) {
// AuthenticationException가 아니라면 AccessDeniedException인지 확인
securityException = (AccessDeniedException) this.throwableAnalyzer
.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
}
// 둘 다 아니라면 예외처리(Runtime or ServletException) - (1)
if (securityException == null) {
rethrow(ex);
}
// 다른 에러라면 응답이 이미 커밋되어 있는지를 확인하고 예외 처리를 던짐 - (2)
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception "
+ "because the response is already committed.", ex);
}
// (1), (2)를 무사히 지나왔으므로 handleSpringSecurityException() 메서드를 통해 리다이렉트 또는 예외처리한다.
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);
}
}
인증 예외(AuthenticationException)가 발생했다면,
handleAuthenticationException()이라는 핸들링 메서드를 실행한다.
private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
this.logger.trace("Sending to authentication entry point since authentication failed", exception);
sendStartAuthentication(request, response, chain, exception);
}
...
...
...
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
// SEC-112: Clear the SecurityContextHolder's Authentication, as the
// existing Authentication is no longer considered valid
SecurityContext context = SecurityContextHolder.createEmptyContext();
SecurityContextHolder.setContext(context);
this.requestCache.saveRequest(request, response);
this.authenticationEntryPoint.commence(request, response, reason);
}
- 로그인 페이지로 리다이렉트하기 전, 먼저 사용자의 요청 정보를 세션에 저장한다
(해당 작업은 HttpSessionRequestCashe 클래스가 수행한다.) - AuthenticationEntryPoint 클래스를 사용하여 Security Context에 만들어놨었던 인증 객체를 폐기하고 사용자가 다시 인증할 수 있도록 로그인 페이지로 리다이렉트한다.
- 재로그인이 성공했다면 저장했던 세션 정보를 통해 사용자의 요청 페이지로 이동시킨다.
인가 예외(AccessDeniedException)가 발생했다면,
handleAccessDeniedException()이라는 핸들링 메서드를 실행한다.
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)) {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
authentication), exception);
}
sendStartAuthentication(request, response, chain,
new InsufficientAuthenticationException(
this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
if (logger.isTraceEnabled()) {
logger.trace(
LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
exception);
}
this.accessDeniedHandler.handle(request, response, exception);
}
}
- AccessDeniedHandler(AccessDeniedHandlerImpl 클래스)를 호출해서 핸들링한다.
- 리다이렉트 페이지가 없을 시 403 에러페이지로 이동한다.
- 리다이렉트 페이지가 있을 시 해당 페이지로 이동한다.