UsernamePasswordAuthenticationFilter는 어떤 필터인가?
개인 프로젝트, 팀 프로젝트에 이어 사내 프로젝트까지 스프링 시큐리티를 담당하며 시큐리티에 대한 이해도가 많이 필요하게 되었다.
특히 시큐리티는 필터 체인을 통해 여러 가지 인증/인가와 관련된 필터를 타게 되는데 해당 필터들의 대한 이해가 동반되지 않으면 스프링 시큐리티를 사용하는 데 있어 많은 어려움을 겪게 된다.
우선 스프링 시큐리티를 활성화하게 되면 아래와 같은 기본적으로 정의된 필터를 타면서 인증/인가 절차를 진행하게 된다.
그 중에 UsernamePasswordAuthenticationFilter라는 것이 6번째 필터로 존재하게 되는데 해당 필터의 존재의 의미와 역할에 대해 알아보려고 한다.
왜 이렇게 익숙한 이름이지?
처음에 UsernamePasswordAuthenticationFilter를 상세하게 파보려고 했던 이유는 토이 프로젝트에서 사용했었던 JwtFilter 때문이었다.
아래는 예전에 사용했던 JwtFilter였다.
package com.park.restapi.util.jwt;
import com.park.restapi.domain.member.entity.Member;
import com.park.restapi.domain.member.entity.MemberRole;
import com.park.restapi.domain.member.repository.MemberRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final MemberRepository memberRepository;
@Override // 이 주소로 오는 건 토큰 없어도 됨.
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
return path.startsWith("/api/authentications/send") || path.startsWith("/api/email")
|| path.startsWith("/login") || path.startsWith("/api/auth/refresh-token")
|| path.startsWith("/oauth2/authorization/kakao") || path.startsWith("/ws")
|| path.startsWith("/api/members/login") || path.startsWith("/api/members/logout");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("jwt 필터 동작");
Optional<String> accessTokenOptional = findAccessToken(request, "accessToken");
// 비로그인 사용자를 위해 /api/post 경로에 대해 GUEST 권한 부여
if (accessTokenOptional.isEmpty() && request.getRequestURI().startsWith("/api/posts")) {
log.info("비로그인 사용자에게 GUEST 권한 부여");
List<SimpleGrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("GUEST"));
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(null, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
return;
}
// 쿠키 자체가 없으면 401 에러 발생
if (accessTokenOptional.isEmpty()) {
log.error("요청 경로 : " + request.getRequestURI() + "/ 쿠키 없음.");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "쿠키가 존재하지 않습니다.");
return;
}
// 액세스 토큰 추출
String accessToken = accessTokenOptional.get();
// userId 토큰에서 꺼냄.
try {
TokenInfo tokenInfo = jwtService.getUserId(accessToken);
log.info("userId:{}번 유저 토큰 추출 완료", tokenInfo.getUserId());
// 토큰이 만료됐으면 401 리턴
if (tokenInfo.isExpired()) {
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "액세스 토큰이 만료 되었습니다.");
return; // 여기서 처리 종료
}
if (!authenticateUser(request, response, tokenInfo.getUserId()))
return;
log.info("유저 인증 완료");
filterChain.doFilter(request, response);
} catch (Exception e) {
log.error("JWT 필터 처리 중 예외 발생", e);
e.printStackTrace();
}
}
// 공통 응답 메시지
private void sendErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws
IOException {
response.setStatus(status.value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(String.format("{\"error\": \"%s\"}", message));
}
// 쿠키에서 토큰 찾기
private Optional<String> findAccessToken(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
return cookies == null ? Optional.empty() : Arrays.stream(cookies)
.filter(cookie -> name.equals(cookie.getName()))
.findFirst()
.map(Cookie::getValue);
}
// 유저 인증
private boolean authenticateUser(HttpServletRequest request, HttpServletResponse response, Long userId) throws
IOException {
Optional<Member> byIdLogin = memberRepository.findByIdFetchRole(userId);
if (byIdLogin.isEmpty()) {
log.info("유저 데이터 없음");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유저 데이터 없음");
return false;
}
Member member = byIdLogin.get();
List<MemberRole> memberRoles = member.getMemberRoles();
List<SimpleGrantedAuthority> authorities = memberRoles.stream()
.map(memberRole -> new SimpleGrantedAuthority(memberRole.getRole().name()))
.collect(Collectors.toList());
// 유저 인증 객체
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(member.getId(), null, authorities);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
return true;
}
}
위 필터의 목적은 사용자의 요청에서 JWT를 꺼내 검증하고 인증 객체를 생성하는 필터였다.
그리고 SecutiryConfig에는 항상 addFilterBefore 메서드를 통해 UsernamePasswordAuthenticationFilter 이전에 먼저 탈 수 있도록 설정했었다.
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
왜 UsernamePasswordAuthenticationFilter 이전에 JwtFilter를 탔어야 했나?
위 질문에 대해서는 늘 답변하지 못했었다. 그냥 남들이 다 UsernamePasswordAuthenticationFilter 이전에 JWT를 검증하도록 만드니깐 나도 만들었었던 것이다.
그러다 사내 프로젝트에서 시큐리티 설정을 거의 담당했었는데 이때 FormLogin을 사용하면서 UsernamePasswordAuthenticationFilter의 개념과 역할에 대해 제대로 짚고 넘어갔어야 했다.
UsernamePasswordAuthenticationFilter
이름 그대로 해석해보면 '유저이름(ID), 패스워드로 인증을 진행하는 필터'라고 정의할 수 있다.
/*
* 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 jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationManager;
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;
/**
* 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 {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
"POST");
private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
private boolean postOnly = true;
public UsernamePasswordAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(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>
*/
@Nullable
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(this.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>
*/
@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(this.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(this.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 this.usernameParameter;
}
public final String getPasswordParameter() {
return this.passwordParameter;
}
}
위 코드만 봐서는 무슨 역할인지 이해하기 어려우니 아래에서 차근차근 알아보자.
처음에 눈에 띄는 부분은 정적 필드다.
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
"POST");
id 파라미터 이름으로 "username", 비밀번호 파라미터 이름으로 "password"를 정의하고 있으며
/login에 POST 요청으로 들어올 경우 동작한다고 짐작이 가능하다.
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
위 코드는 UsernamePasswordAuthenticationFilter의 핵심 로직인 attemptAuthentication이다.
먼저 /login 경로로 POST 요청을 보낼 경우 HttpServletRequest에서 username과 password를 추출한다.
UsernamePasswordAuthenticationFilter를 동작시키기 위해서는 SecurityConfig에 formLogin 활성화를 진행해주어야 한다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(login -> login
.loginProcessingUrl("/api/login")
.usernameParameter("id")
.passwordParameter("password")
)
.build();
}
loginProcessingUrl
클라이언트에게서 로그인 요청을 처리받을 url(default는 /login만 받지만 해당 옵션으로 변경할 수 있다.)
usernameParameter
클라이언트에게서 받을 username 파라미터 이름(default는 username)
passwordParameter
클라이언트에게서 받을 password 파라미터 이름(default는 password)
즉 클라이언트에서 '/api/login'으로 요청을 보내고 'id', 'password'라는 파라미터 이름으로 값을 보내면 UsernamePasswordAuthenticationFilter가 동작하게 된다.
(값이 안 들어오는 경우 JSON인지 확인해야 합니다. form-data 형식으로 보낼 경우에만 값 파싱이 정상적으로 진행됩니다.)
실제로 요청을 보내면 'username'의 파라미터 이름이 'id'로 바뀐 것을 확인할 수 있다.
또한 파라미터에 이름에 맞게 form-data로 보내준 값을 정확하게 받는 것도 확인할 수 있다.
위에서 받은 값들을 통해 authRequest라고 인증 요청 객체를 생성하게 된다.
return this.getAuthenticationManager().authenticate(authRequest);
그리고 AuthenticationManager에게 authRequest를 던져서 인증 절차를 진행하도록 만드는 것.
다시 정리하면 UsernamePasswordAuthenticationFilter의 역할은
1. 특정 url로 들어오는 form-data에서 id, password 추출
2. id, password로 authRequest(인증 요청 객체)를 생성
3. AuthenticationManager에게 인증 위임
위 절차가 끝이다.
AuthenticationManager에서는 다시 AuthenticationProvider에게 위임시켜 유저 정보를 찾고 인증 토큰을 생성하게 되는 것이다.
그럼 다시 처음의 물음으로 돌아가보자.
왜 UsernamePasswordAuthenticationFilter 이전에 JwtFilter를 탔어야 했나?
UsernamePasswordAuthenticationFilter는 username, password를 form-data로 받아서 AuthenticationManager에게 위임을 진행하는 Filter이다.
반면에 JwtFilter는 JWT가 유효하면 파싱 해서 유저의 인증 절차를 진행해서 시큐리티컨텍스트홀더에 인증 토큰을 저장하게 된다.
또한 UsernamePasswordAuthenticationFilter는 Session 기반에서 사용되는 필터이다. 반면에 JWT를 사용하면 UsernamePasswordAuthenticationFilter를 탈 필요 없이 자체적인 인증 필터를 정의하기에 앞 단에서 먼저 타도록 설정하게 된다.