🧐 JSON으로 로그인처리 하기
스프링 시큐리티의 formLogin()을 사용하면 오로지 Content-Type이 x-www-form-urlencoded인 방식으로만 데이터를 받을 수 있습니다.
formLogin을 사용하지 않고, JSON으로 username과 password를 받아서 로그인 처리를 진행하는 방법을 알아보도록 하겠습니다.
🧐 formLogin()의 작동방식
시큐리티의 formLogin()을 활성화 시키면 다음 사진과 같이 FormLoginConfigurer가 활성화 되는것을 알 수 있습니다.
이때 FormLoginConfigurer 에서는 UsernamePasswordAuthenticationFilter란 것을 사용하는데 이에 대한 작동방식을 확인해 보도록 하겠습니다.
🧐 UsernamePasswordAuthenticationFilter
모든 코드를 볼 필요 없이, 왜 POST 요청의 x-www-form-urlencoded 방식으로만 로그인이 가능한지에 대해서만 알아보겠습니다.
우선 POST가 아닌 요청인 경우에 예외를 발생하는 부분은 쉽게 찾을 수 있습니다.
이후 username과 password를 받아오는 obtain~~~ 코드를 보면 다음과 같습니다.
보이는 바와 같이 getParameter로 데이터를 가져오기 때문에 parameter 형식이 아닌 JSON 데이터는 가져올 수 없습니다.
🧐 어떻게 JSON으로 로그인 하지?
AbstractAuthenticationProcessingFilter나 UsernamePasswordAuthenticationFilter을 상속하여 처리할 수 있습니다.
여기서는 AbstractAuthenticationProcessingFilter를 상속받아 처리해보겠습니다.
우선 시작 전에 formlogin()의 전체적인 인증의 흐름을 살펴보겠습니다..
🧐 formlogin() 인증 절차
우선 /login 으로 요청이 들어오면 UsernamePasswordAuthenticationFilter가 작동합니다.
(username과 password를 사용하여 인증을 처리하며, 기본 url은 /login이고, POST의 요청을 처리하는 것을 알 수 있습니다.)
attemptAuthentication() 메소드가 호출되어 인증을 처리합니다.
이때 username과 password로 UsernamePasswordAuthenticationToken을 만든 후, 이를 사용하여 AuthenticationManager의 authenticate()를 호출합니다.
기본적으로 AuthenticationManager로는 ProviderManager를 사용합니다.
ProviderManager는 AuthenticationProvider를 가지고 있으며, 만약 자신이 가진 AuthenticationProvider에서 처리할 수 없다면 자신의 부모에게 위임합니다.
UsernamePasswordAuthenticationToken을 사용하는 경우,
(AbstractUserDetailsAuthenticationProvider를 상속받은)DaoAuthenticationProvider에서 처리됩니다.
AbstractUserDetailsAuthenticationProvider의 authenticate() 메서드를 살펴보겠습니다.
간단하게만 살펴보면 retrieveUser() 메서드와 additionalAuthenticationChecks() 를 통해 인증을 진행합니다.
우선 retrieveUser()부터 살펴보면, 이는 DaoAuthenticationProvider 에서 다음과 같이 구현되어 있습니다.
다음으로는 additionalAuthenticationChecks() 입니다.
비밀번호에 대한 일치여부를 확인하는 것을 알 수 있습니다.
이제 JSON으로 로그인 처리를 하기 위한 방법을 생각해보겠습니다.
AbstractAuthenticationProcessingFilter을 상속받아 JSON으로 username과 password를 알아내어 UsernamePasswordAuthenticationToken을 생성한 뒤, UserDetailsService을 상속받아 loadUserByUsername()을 오버라이딩하여 Username을 통해 해당되는 유저의 UserDetails를 생성한다면 이후 비밀번호 체크 과정은 이미 구현되어있는 DaoAuthenticationProvider에서 진행되어 로그인 처리를 할 수 있습니다.
구현해 보도록 하겠습니다.
🧐 로그인 JSON으로 처리하기
AbstractAuthenticationProcessingFilter 상속
@Slf4j
public class JsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = "/login"; // /login/oauth2/ + ????? 로 오는 요청을 처리할 것이다
private static final String HTTP_METHOD = "POST"; //HTTP 메서드의 방식은 POST 이다.
private static final String CONTENT_TYPE = "application/json";//json 타입의 데이터로만 로그인을 진행한다.
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); //=> /login 의 요청에, POST로 온 요청에 매칭된다.
private final ObjectMapper objectMapper;
public JsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper,
AuthenticationSuccessHandler authenticationSuccessHandler, // 로그인 성공 시 처리할 핸들러
AuthenticationFailureHandler authenticationFailureHandler // 로그인 실패 시 처리할 핸들러
) {
super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 위에서 설정한 /oauth2/login/* 의 요청에, GET으로 온 요청을 처리하기 위해 설정한다.
this.objectMapper = objectMapper;
setAuthenticationSuccessHandler(authenticationSuccessHandler);
setAuthenticationFailureHandler(authenticationFailureHandler);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
LoginDto loginDto = objectMapper.readValue(StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8), LoginDto.class);
String username = loginDto.getUsername();
String password = loginDto.getPassword();
if (username == null || password == null) {
throw new AuthenticationServiceException("DATA IS MISS");
}
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
@Data
private static class LoginDto {
String username;
String password;
}
}
중요한 코드는 다음과 같습니다.
- 우선 POST가 아니거나, Content Type이 JSON이 아닌 경우 예외를 발생시킵니다.
- LoginDto 클래스를 만들어서 request의 데이터를 파싱합니다.
- 비어있는 값이 있으면 오류를 발생시키며, 이후 AuthenticationManager의 authenticate를 통해 검증합니다.
로그인 성공과 실패 시 처리할 핸들러는 다음과 같이 간단하게만 구현하겠습니다.
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("LoginSuccessHandler.onAuthenticationSuccess");
}
}
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println("LoginFailureHandler.onAuthenticationFailure");
response.setStatus(403);
}
}
🧐 설정하기
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final ObjectMapper objectMapper;
private final UserDetailsService loginService;
private final LoginSuccessHandler loginSuccessHandler;
private final LoginFailureHandler loginFailureHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.formLogin().disable();
http.authorizeHttpRequests()
.requestMatchers("/login").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jsonUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordAuthenticationFilter() {
JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordAuthenticationFilter = new JsonUsernamePasswordAuthenticationFilter(objectMapper, loginSuccessHandler, loginFailureHandler);
jsonUsernamePasswordAuthenticationFilter.setAuthenticationManager(authenticationManager());
return jsonUsernamePasswordAuthenticationFilter;
}
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setPasswordEncoder(passwordEncoder());
provider.setUserDetailsService(loginService);
return new ProviderManager(provider);
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
📔 Reference
'🏝️ Spring > Security' 카테고리의 다른 글
[Spring Security] @WebMvcTest 작성 시 Spring Security로 인해 발생하는 오류들 (4) | 2022.06.29 |
---|---|
[Security] OAuht2 로그인 - AccessToken을 가지고 로그인하는 방법 (REST API)(카카오, 네이버, 구글) (5) | 2021.12.28 |
[Security] 스프링 시큐리티 - 로그인(소셜 로그인 포함)성공 시 후처리 하는 방법 (0) | 2021.12.12 |
[Security] 스프링 시큐리티 OAuth2 카카오톡 로그인 구현하기 With Rest API (0) | 2021.12.11 |
[Security] Spring Security - OAuht2 로그인 작동원리 (2) | 2021.12.11 |