최근 기존에 NodeJS와 Typescript, NestJS으로 이루어진 API 서버를 Java와 Spring Boot 환경으로 마이그레이션 작업을 진행하고 있다. 그중 Spring Security를 적용하면서 REST API 형태로 JSON으로 Login 요청을 날리면 Spring Security에서는 데이터를 받지 못하고 해당 요청을 Block 하는 이슈가 있어서 이를 확인하고 해결한 과정을 기록해본다.
Spring Security는 여러개의 Filter들이 묶여서 동작하는 Filter Chain으로 이루어져 있다. 그렇다면 Login을 담당하는 Filter는 어떤 것일까? SecurityConfiguration 클래스를 작성하다 보면 다음과 같은 메소드를 볼 수 있을 것이다.
package com.tistory.johnmark.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Slf4j
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
/**
* Spring Security Configuration
*
* @param http - http protocol security instance
* @throws Exception - Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// Security configuration changing method
http.formLogin()
.usernameParameter("email")
.passwordParameter("password");
}
}
HttpSecurity클래스의 객체인 http에서 formLogin() 메소드를 호출하면, 로그인 관련 처리를 Spring Security에서 하겠다는 뜻이다. Spring Security에서 기본 로그인 파라미터는 username과 password인데, 넘어오는 파라미터의 이름은 요구사항에 따라 다를 수도 있으니 이를 usernameParameter와 passwordParameter 메소드로 Spring Security에게 사용되는 파라미터명을 알려줄 수 있다. 그럼 formLogin() 메소드는 어떻게 동작하는지 한번 살펴보자.
HttpSecurity클래스에서 formLogin() 메소드를 호출하면 위와 같이 FormLoginConfigurer 클래스를 생성하여 적용하는 것으로 보인다. 그럼 FormLoginConfigurer 클래스를 한번 더 살펴보자.
FormLoginConfigurer 클래스를 살펴보면 AbstractAuthenticationFilterConfigurer를 상속받아 구현하고 있는것으로있는 것으로 보이며, 생성자에서 UsernamePasswordAuthenticationFilter와 defaultLoginProcessingUrl을 부모 생성자에 전달하고 있는 것으로 보인다. 즉 이를 보면 formLogin 메소드를 호출하면 UsernamePasswordAuthenticationFilter가 적용된다는 사실을 알 수 있을 것이다. 그럼 UsernamePasswordAuthenticationFilter를 살펴보자.
/*
* Copyright 2004, 2005, 2006 Acegi Technology Pty Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.authentication;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* Processes an authentication form submission. Called
* {@code AuthenticationProcessingFilter} prior to Spring Security 3.0.
* <p>
* Login forms must present two parameters to this filter: a username and password. The
* default parameter names to use are contained in the static fields
* {@link #SPRING_SECURITY_FORM_USERNAME_KEY} and
* {@link #SPRING_SECURITY_FORM_PASSWORD_KEY}. The parameter names can also be changed by
* setting the {@code usernameParameter} and {@code passwordParameter} properties.
* <p>
* This filter by default responds to the URL {@code /login}.
*
* @author Ben Alex
* @author Colin Sampaleanu
* @author Luke Taylor
* @since 3.0
*/
public class UsernamePasswordAuthenticationFilter extends
AbstractAuthenticationProcessingFilter {
// ~ Static fields/initializers
// =====================================================================================
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
private boolean postOnly = true;
// ~ Constructors
// ===================================================================================================
public UsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
// ~ Methods
// ========================================================================================================
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
/**
* Enables subclasses to override the composition of the password, such as by
* including additional values and a separator.
* <p>
* This might be used for example if a postcode/zipcode was required in addition to
* the password. A delimiter such as a pipe (|) should be used to separate the
* password and extended value(s). The <code>AuthenticationDao</code> will need to
* generate the expected password in a corresponding manner.
* </p>
*
* @param request so that request attributes can be retrieved
*
* @return the password that will be presented in the <code>Authentication</code>
* request token to the <code>AuthenticationManager</code>
*/
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(passwordParameter);
}
/**
* Enables subclasses to override the composition of the username, such as by
* including additional values and a separator.
*
* @param request so that request attributes can be retrieved
*
* @return the username that will be presented in the <code>Authentication</code>
* request token to the <code>AuthenticationManager</code>
*/
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(usernameParameter);
}
/**
* Provided so that subclasses may configure what is put into the authentication
* request's details property.
*
* @param request that an authentication request is being created for
* @param authRequest the authentication request object that should have its details
* set
*/
protected void setDetails(HttpServletRequest request,
UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
/**
* Sets the parameter name which will be used to obtain the username from the login
* request.
*
* @param usernameParameter the parameter name. Defaults to "username".
*/
public void setUsernameParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.usernameParameter = usernameParameter;
}
/**
* Sets the parameter name which will be used to obtain the password from the login
* request..
*
* @param passwordParameter the parameter name. Defaults to "password".
*/
public void setPasswordParameter(String passwordParameter) {
Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
this.passwordParameter = passwordParameter;
}
/**
* Defines whether only HTTP POST requests will be allowed by this filter. If set to
* true, and an authentication request is received which is not a POST request, an
* exception will be raised immediately and authentication will not be attempted. The
* <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed
* authentication.
* <p>
* Defaults to <tt>true</tt> but may be overridden by subclasses.
*/
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getUsernameParameter() {
return usernameParameter;
}
public final String getPasswordParameter() {
return passwordParameter;
}
}
여기서 왜 Spring Security에서 FormLogin을 활성화 한다음에 REST API로 JSON 형태의 로그인 요청을 보내면 데이터를 받지 못하여 요청이 Block 되는지 알 수 있다. request에서 로그인 관련 파라미터를 추출하는 메소드인 obtainUsername과 obtainPassword 메소드의 구현이 request.getParameter()로 이루어져 있기 때문이다.
이는 Content-Type이 application/x-www-form-urlencoded 인 form 요청이여야만 request에서 파라미터를 성공적으로 가져오고 이를 AuthenticationToken으로 만들고 이를 검증하기 위해 AuthenticationManager에게 전달할 수 있다는 사실을 알려준다.
obtainUsername 및 obtainPassword 메소드는 Authentication과정을 시도하는 attemptAuthentication메소드에서 호출이 된다. 그렇다면 해당 메소드 내부에서 request의 타입이 json 형태의 요청인 경우를 이를 파싱 하는 코드를 작성하면 Form 요청 이외 JSON 요청도 처리할 수 있다. 그래서 다음과 같이 CustomUsernamePasswordAuthenticationFilter 클래스를 작성했다.
package com.tistory.johnmark.security.filter;
import java.io.IOException;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.util.MimeTypeUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Authentication attemptAuthentication(
HttpServletRequest request, HttpServletResponse response
) throws AuthenticationException {
UsernamePasswordAuthenticationToken authenticationToken;
if (request.getContentType().equals(MimeTypeUtils.APPLICATION_JSON_VALUE)) {
// json request
try {
// read request body and mapping to login dto class by object mapper
LoginDto loginDto = objectMapper.readValue(
request.getReader().lines().collect(Collectors.joining()), LoginDto.class);
authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.username, loginDto.password);
} catch (IOException e) {
e.printStackTrace();
throw new AuthenticationServiceException("Request Content-Type(application/json) Parsing Error");
}
} else {
// form-request
String username = obtainUsername(request);
String password = obtainPassword(request);
authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
}
this.setDetails(request, authenticationToken);
return this.getAuthenticationManager().authenticate(authenticationToken);
}
class LoginDto {
private String username;
private String password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
}
Form 요청이 들어온 경우 기존 UsernamePasswordAuthenticationFilter와 동일하게 동작하고, JSON 요청이 들어온 경우 request의 body를 읽어, LoginDto 클래스에 ObjectMapper를 통해 JSON 요청이 맵핑 되도록 코드를 작성했다. 예시 코드이기에 Dto Class를 해당 커스텀 필터의 이너 클래스로 선언해놨다. 해당 Login Dto Class 를 통해 UsernamePasswordAuthenticationToken을 만들어 Authenticationmanager가 authentication과정을 진행할 수 있도록 구현했다. 이후 UserDetailsService에서 요청에 포함된 Username을 기준으로 DB에서 사용자를 찾고 autenticationManager에 등록된 PasswordEncoder로 패스워드를 비교하여 로그인과정을 진행할 것이다.
위 구현된 CustomUsernamePasswordAuthenticationFilter를 적용하려면 formLogin을 disable시키고 새로 구현한 Filter를 등록시켜줘야 한다. 이는 다음과 같이 하면 된다.
package com.tistory.johnmark.config;
import com.tistory.johnmark.security.RESTAuthenticationEntryPoint;
import com.tistory.johnmark.security.UserDetailsServiceImpl;
import com.tistory.johnmark.security.filter.CustomUsernamePasswordAuthenticationFilter;
import com.tistory.johnmark.security.handler.RESTLoginFailureHandler;
import com.tistory.johnmark.security.handler.RESTLoginSuccessHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Project:
* DATE: 2019-04-16
* AUTHOR: JohnMark (Chang Jeong Hyeon)
* EMAIL: practice1356@gmail.com
*/
@Slf4j
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final UserDetailsServiceImpl userDetailsService;
private final PasswordEncoder passwordEncoder;
private final RESTLoginFailureHandler authenticationFailureHandler;
private final RESTLoginSuccessHandler authenticationSuccessHandler;
private final RESTAuthenticationEntryPoint authenticationEntryPoint;
@Value("${spring.profiles.active}")
String serverMode;
public SecurityConfiguration(UserDetailsServiceImpl userDetailsService, PasswordEncoder passwordEncoder, RESTLoginFailureHandler authenticationFailureHandler, RESTLoginSuccessHandler authenticationSuccessHandler, RESTAuthenticationEntryPoint authenticationEntryPoint) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
this.authenticationFailureHandler = authenticationFailureHandler;
this.authenticationSuccessHandler = authenticationSuccessHandler;
this.authenticationEntryPoint = authenticationEntryPoint;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
}
/**
* Spring Security Configuration
*
* @param http - http protocol security instance
* @throws Exception - Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint);
http.authorizeRequests().antMatchers("/user/login").permitAll();
http.formLogin().disable();
// 새로구현한 Filter를 UsernamePasswordAuthenticationFilter layer에 삽입
http.addFilterAt(getAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
protected CustomUsernamePasswordAuthenticationFilter getAuthenticationFilter() {
CustomUsernamePasswordAuthenticationFilter authFilter = new CustomUsernamePasswordAuthenticationFilter();
try {
authFilter.setFilterProcessesUrl("/user/login");
authFilter.setAuthenticationManager(this.authenticationManagerBean());
authFilter.setUsernameParameter("email");
authFilter.setPasswordParameter("password");
authFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
authFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
} catch (Exception e) {
e.printStackTrace();
}
return authFilter;
}
}
'Programming > Spring' 카테고리의 다른 글
[SpringBoot] Dockerizing a Spring Boot Application (0) | 2019.11.19 |
---|---|
[Spring Boot] Java Servlet Filter for Logging Request Parameter (2) | 2019.11.12 |
[SpringBoot] Redis Channel Subscribe with MessagePack (0) | 2019.09.18 |
[SpringBoot] Redis Publish Channel Subscribe (0) | 2019.09.18 |
[Spring Boot] @Schedule로 스케줄 프로그래밍 하기 (0) | 2019.08.22 |