1. DSL
DSL(Domain-specific language, 도메인 특화 언어)는 특정화된 도메인을 적용하는데 특화된 언어이다. HTML과 같이 웹 전체에서 널리 쓰이는 언어의 개념이 아니라 특정한 도메인에서만 쓰이는 언어라고 보면 된다.
지금 학습하고 있는 맥락에서 이해하자면, filter나 handler 등의 http. 환경설정 메서드의 set를 만든다고 생각하면 될 것 같다. spring security를 전반적으로 다 이해하고 환경설정 set를 만들어서 활용할 정도가 되어야 사용할 것 같다. 그래서 이 부분은 가볍게 이해하고 넘어가면 될 것 같다.
AbstractHttpConfigurer
초기화를 위한 설정 클래스이다. 이 클래스를 이용해서 스프링 시큐리티의 환경설정 set를 만든다. configs 패키지에 AjaxLoginConfigurer 클래스를 추가한다.
강의에서 나온 코드는 아래와 같다.
public final class AjaxLoginConfigurer<H extends HttpSecurityBuilder<H>> extends
AbstractAuthenticationFilterConfigurer<H, AjaxLoginConfigurer<H>, AjaxLoginProcessingFilter> {
private AuthenticationSuccessHandler successHandler;
private AuthenticationFailureHandler failureHandler;
private AuthenticationManager authenticationManager;
public AjaxLoginConfigurer() {
super(new AjaxLoginProcessingFilter(), null);
}
@Override
public void init(H http) throws Exception {
super.init(http);
}
@Override
public void configure(H http) {
if(authenticationManager == null){
authenticationManager = http.getSharedObject(AuthenticationManager.class);
}
getAuthenticationFilter().setAuthenticationManager(authenticationManager);
getAuthenticationFilter().setAuthenticationSuccessHandler(successHandler);
getAuthenticationFilter().setAuthenticationFailureHandler(failureHandler);
SessionAuthenticationStrategy sessionAuthenticationStrategy = http
.getSharedObject(SessionAuthenticationStrategy.class);
if (sessionAuthenticationStrategy != null) {
getAuthenticationFilter().setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
}
RememberMeServices rememberMeServices = http
.getSharedObject(RememberMeServices.class);
if (rememberMeServices != null) {
getAuthenticationFilter().setRememberMeServices(rememberMeServices);
}
http.setSharedObject(AjaxLoginProcessingFilter.class,getAuthenticationFilter());
http.addFilterBefore(getAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
public AjaxLoginConfigurer<H> successHandlerAjax(AuthenticationSuccessHandler successHandler) {
this.successHandler = successHandler;
return this;
}
public AjaxLoginConfigurer<H> failureHandlerAjax(AuthenticationFailureHandler authenticationFailureHandler) {
this.failureHandler = authenticationFailureHandler;
return this;
}
public AjaxLoginConfigurer<H> setAuthenticationManager(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
return this;
}
@Override
protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) {
return new AntPathRequestMatcher(loginProcessingUrl, "POST");
}
}
HttpSecurityBuilder, AbstractAuthenticationFilter
우선 이 두 클래스를 상속받으면서, 각종 알 수 없는 Generic 타입을 이용한다. 지금 수준에서 이해하긴 어려울 것 같다.
configure 메서드에서 getAuthenticationFilter 메서드를 통해서 인증 성공, 실패 시 handler를 지정해주었다. 그리고 getSharedObject 메서드를 통해서 AuthenticationManager, Session, RememberMe 객체를 넣어주는 것을 알 수 있다. 제네릭으로 되어있어서 아직은 무슨 내용인지 알기가 어려운데, 아마 메인이 되는 SecurityConfig 파일에 전달할 객체를 지정하는 것 같다.
SecurityConfig 파일 수정
이제 작성한 Configurer 객체를 SecurityConfig에 넣어주면 된다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/api/**")
.authorizeRequests()
.antMatchers("/api/messages").hasAuthority("MANAGER")
.anyRequest().authenticated()
// .and()
// .addFilterBefore(ajaxLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) //기존 필터 앞에 위치할때 사용하는 method
;
http
.exceptionHandling()
.authenticationEntryPoint(new AjaxLoginAuthenticationEntryPoint())
.accessDeniedHandler(new AjaxAccessDeniedHandler())
;
http.csrf().disable(); //기본적으로 csrf 토큰값을 들고 요청을 해야되는데, 임시적으로 off 한다.
customConfigurerAjax(http);
}
private void customConfigurerAjax(HttpSecurity http) throws Exception {
http
.apply(new AjaxLoginConfigurer<>())
.successHandlerAjax(ajaxAuthenticationSuccessHandler())
.failureHandlerAjax(ajaxAuthenticationFailureHandler())
.setAuthenticationManager(authenticationManagerBean())
.loginProcessingUrl("/api/login")
;
}
customConfigurerAjax 메서드를 만들고, http .apply 메서드를 통해 Configurer를 연결시켜 주었다. 그리고 여기서 success, failure, AuthenticationManager 및 로그인 url 설정을 통해서 기존 ajaxFilter가 하던 기능을 위임시켜주었다. 실제 원래 사용하던 ajaxLoginProcessingFilter Bean은 주석처리 해줘도 정상작동한다.
2. 로그인 Ajax 구현 & CSRF
이 글 시리즈의 8편 CSRF에서 CSRF는 클라이언트가 서버에 요청을 보낼 때마다 발급되고, 클라이언트도 이 값을 hidden 태그 형태로 갖고 있다가 요청에 실어서 보낸다고 배웠었다.
이번 장에서는 클라이언트 페이지에 이 csrf 값을 담고 서버로 보내주는 형태를 직접 만들어본다. 이전 글에서 언급한대로 csrf Filter는 disable 처리를 하지 않는 한 자동으로 작동된다. 따라서 클라이언트측에서 csrfHeader에 csrfToken을 넣어주어야 한다. login.html, home.html 파일을 아래와 같이 수정한다.
login.html 파일
Body 부분
form 태그 내부의 맨 아래 button 태그가 onclick 이벤트가 달려서 formLogin() 메서드를 호출하는 것을 볼 수 있다. 여기서 csrf 관련 처리를 하게 된다. 이 부분은 아래 header 및 script 태그 부분에 기술한다.
<body>
<div th:replace="layout/top::header"></div>
<div class="container text-center">
<div class="login-form d-flex justify-content-center">
<div class="col-sm-5" style="margin-top: 30px;">
<div class="panel">
<p>아이디와 비밀번호를 입력해주세요</p>
</div>
<div th:if="${param.error}" class="form-group">
<span th:text="${exception}" class="alert alert-danger">잘못된 아이디나 암호입니다</span>
</div>
<form th:action="@{/login_proc}" class="form-signin" method="post">
<input type="hidden" th:value="secret" name="secret_key" />
<div class="form-group">
<input type="text" class="form-control" name="username" placeholder="아이디" required="required" autofocus="autofocus">
</div>
<div class="form-group">
<input type="password" class="form-control" name="password" placeholder="비밀번호" required="required">
</div>
<button type="submit" onclick="formLogin()" id="formbtn" class="btn btn-lg btn-primary btn-block">로그인</button>
<!--<button type="submit" class="btn btn-lg btn-primary btn-block">로그인</button>-->
</form>
</div>
</div>
</div>
</body>
</html>
header 및 script 태그
meta 태그를 이용해서 csrf 정보를 기본 정보로 담도록 처리한다. 서버에서 지정하는 csrf의 headerName의 기본 값(8. 위조 방지 글에서 배움) 으로 "_csrf"의 token과 headerName을 가져와서 th:content로 매핑해주었다.
그리고 formLogin 메서드에서는 csrfHeader, csrfToken 변수를 meta 태그에 입력된 content 값을 받아오도록 하였고, $.ajax 문법을 통해 ajax 요청으로 서버에 이 csrf값을 담아서 보내도록 설정해준다. setRequestHeader로 헤더값에 csrf 값을 넣고, 요청 성공과 실패 시 각각 success, error 에서 처리하도록 지정하였다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<meta id="_csrf" name="_csrf" th:content="${_csrf.token}"/>
<meta id="_csrf_header" name="_csrf_header" th:content="${_csrf.headerName}"/>
<head th:replace="layout/header::userHead"></head>
<script>
function formLogin(e) {
var username = $("input[name='username']").val().trim();
var password = $("input[name='password']").val().trim();
var data = {"username" : username, "password" : password};
var csrfHeader = $('meta[name="_csrf_header"]').attr('content')
var csrfToken = $('meta[name="_csrf"]').attr('content')
$.ajax({
type: "post",
url: "/api/login",
data: JSON.stringify(data),
dataType: "json",
beforeSend : function(xhr){
xhr.setRequestHeader(csrfHeader, csrfToken);
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
xhr.setRequestHeader("Content-type","application/json");
},
success: function (data) {
console.log(data);
window.location = '/';
},
error : function(xhr, status, error) {
console.log(error);
window.location = '/login?error=true&exception=' + xhr.responseText;
}
});
}
</script>
home.html 파일도 login.html 파일과 거의 유사하게 작성되었다. 따로 볼 필요는 없을 것 같다.
Elements 일부
home.html 파일을 예로 브라우저의 Elements 부분을 보면, meta 태그와 script 내용이 잘 들어가 있는 것을 확인할 수 있다.
CSRF Filter 동작
이제 CsrfFilter 파일의 doFilterInternal 부분에 break point를 걸고 요청을 보내면 요청 시마다 debugging에 걸리는 것을 확인할 수 있다.
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
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);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
filterChain.doFilter(request, response);
} else {
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!csrfToken.getToken().equals(actualToken)) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request));
}
if (missingToken) {
this.accessDeniedHandler.handle(request, response, new MissingCsrfTokenException(actualToken));
} else {
this.accessDeniedHandler.handle(request, response, new InvalidCsrfTokenException(csrfToken, actualToken));
}
} else {
filterChain.doFilter(request, response);
}
}
}
메서드 내 3번째 줄에서 tokenRepository.loadToken 메서드를 통해 token 정보를 불러온다.
그리고 request.setAttibute 부분에서 request 내부(여러번 들어가야함)의 attributes에 "_csrf" 속성으로 token 값을 set 해준다.
requireCsrfProtectionMatcher
csrfFilter의 doInternalFilter 메서드의 아랫 부분만 다시 살펴보자. 여기서 requireCsrfProtectionMatcher 이 요청의 url 정보와 기존 url 정보를 비교한다. 이 부분은 클라이언트에서 csrf Header 값을 실어서 보내는 경우 success시와 fail시에 이동하는 url과 최초 요청의 url을 비교한다.
예를 들어 위에 작성한 login.html의 경우 로그인 버튼을 눌러 submit 요청 시 "/api/login"으로 요청이 가면서 csrf 필터를 거쳐서 csrfToken을 발급받고, 브라우저에서 token값을 들고 있는 상황이 된다. 그런데 formLogin 메서드에서 success로 정의한 부분은 첫 번째 요청 성공 시, 다시 windows.location("/")을 통해 다시 홈 주소로 이동하게 하였으므로 한 번 더 csrfFilter를 호출하게 된다. 이때는 처음 요청의 "/api/login" 부분과 "/" 부분의 URL이 다르므로 requireCsrfProtectionMatcher가 url이 다른 것을 인지하고 과연 브라우저가 보낸 csrfToken이 기존과 일치하는지 검사하는 것이다.
if (!this.requireCsrfProtectionMatcher.matches(request)) {
filterChain.doFilter(request, response);
} else {
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!csrfToken.getToken().equals(actualToken)) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request));
}
if (missingToken) {
this.accessDeniedHandler.handle(request, response, new MissingCsrfTokenException(actualToken));
} else {
this.accessDeniedHandler.handle(request, response, new InvalidCsrfTokenException(csrfToken, actualToken));
}
} else {
filterChain.doFilter(request, response);
}
}
CSRFToken의 Session 저장
csrfToken 정보도 session에 저장되는 것 같다. 강의에서 나오지 않은 내용이라 정확하진 않지만, CsrfFilter 부분에 break point를 걸고 loadToken 메서드를 통해 생성되는 token 값을 보면 로그인을 했든 안했든 어떤 자원에 접근 시 똑같은 Token 값이 나오는 것을 확인할 수 있다.
이것은 최초로 도메인에 접근 시 발급되고, 그 이후로는 아래처럼 JSESSIONID에 저장되는 것 같다고 추정된다. 왜냐하면 브라우저에서 JSESSIONID 값을 삭제하고 다시 어떤 자원에 접근하면 위 doInternalFilter의 상단 missingToken 부분에 걸려서 서버에서 다시 토큰을 생성하고 브라우저로 전달하는 과정이 진행되기 때문이다. 불필요한 토큰 생성을 방지하기 위해서 최적화 작업을 해놨다고 일단 이해하면 좋을 것 같다.
참조
1) 인프런 - 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security - 정수원님 강의
https://www.inflearn.com/course/%EC%BD%94%EC%96%B4-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%E