2024.02.17 - [Backend/Spring Security] - Spring Security 내부 흐름(1)에서 Spring Security 내부 흐름에 대해 간략하게 살펴봤다. 이번에는 디버깅을 통해 내부 코드를 파헤쳐보자.
Spring Security를 다루는 과정에서 핵심적인 부분만 확인하고 넘어가는 점을 유의해주세요 !
Spring Security 내부 핵심 파헤치기
AuthenticationFilter 단계
AuthorizationFilter
- 엔드 유저가 접근하고자 하는 URL에 접근을 제한하는 역할을 한다.
doFilter()
- 이 메소드 내부에서는 권한 부여 관리자의 도움을 받아 특정 URL이 공개 URL인지 보안 URL인지 체크한 후 그에 따라 접근 허용 또는 거부한다.
- 공개 URL에 접근하려고 한다면 응답은 엔드 유저에게 자격 증명을 요구하지 않고 바로 웹 페이지가 표시된다.
- 하지만 보안 URL에 접근하고자 한다면 이 필터는 해당 URL에 접근을 멈추고 FilterChain의 doFilter를 수행하는 DefaultLoginPageGenerating Filter로 리다이렉트한다.
public class AuthorizationFilter extends GenericFilterBean {
// ...
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws ServletException, IOException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
if (this.observeOncePerRequest && this.isApplied(request)) {
chain.doFilter(request, response);
} else if (this.skipDispatch(request)) {
chain.doFilter(request, response);
} else {
String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName();
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
if (decision != null && !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
chain.doFilter(request, response);
} finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}
}
// ...
}
DefaultLoginPageGeneratingFilter
- 우리가 보안 URL에 접근하려고 하면 로그인 페이지가 표시되는 것을 확인했는데 그 페이지는 이 필터의 도움으로 생성되었다.
- generateLoginPageHtml에서 로그인 페이지와 관련 html 코드가 프레임워크에 의해 생성된 것을 볼 수 있으며 이는 엔드 유저에게 표시된다.
- 엔드 유저가 본인의 유저 네임, 비밀번호와 같은 자격증명을 입력하고 나면 UsernamePasswordAuthenticationFilter로 간다.
public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
public static final String DEFAULT_LOGIN_PAGE_URL = "/login";
public static final String ERROR_PARAMETER_NAME = "error";
private String loginPageUrl;
private String logoutSuccessUrl;
private String failureUrl;
private boolean formLoginEnabled;
private boolean oauth2LoginEnabled;
private boolean saml2LoginEnabled;
private String authenticationUrl;
private String usernameParameter;
private String passwordParameter;
private String rememberMeParameter;
private Map<String, String> oauth2AuthenticationUrlToClientName;
private Map<String, String> saml2AuthenticationUrlToProviderName;
private Function<HttpServletRequest, Map<String, String>> resolveHiddenInputs = (request) -> {
return Collections.emptyMap();
};
// ....
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
boolean loginError = this.isErrorPage(request);
boolean logoutSuccess = this.isLogoutSuccess(request);
if (!this.isLoginUrlRequest(request) && !loginError && !logoutSuccess) {
chain.doFilter(request, response);
} else {
String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess);
response.setContentType("text/html;charset=UTF-8");
response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
response.getWriter().write(loginPageHtml);
}
}
private String generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess) {
String errorMsg = loginError ? this.getLoginErrorMessage(request) : "Invalid credentials";
String contextPath = request.getContextPath();
StringBuilder sb = new StringBuilder();
sb.append("<!DOCTYPE html>\n");
sb.append("<html lang=\"en\">\n");
sb.append(" <head>\n");
sb.append(" <meta charset=\"utf-8\">\n");
sb.append(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n");
sb.append(" <meta name=\"description\" content=\"\">\n");
sb.append(" <meta name=\"author\" content=\"\">\n");
// ... 로그인 페이지와 관련된 HTML 코드가 존재함 !
// ...
}
UsernamePasswordAuthenticationFilter
- 이 필터 안에는 attemptAuthentication이라는 메소드가 있다. 주된 역할은 수신하는 http의 출력 요청으로부터 유저 네임과 비밀번호를 추출하는 것이다.
- 이 요청으로부터 필터는 유저 네임과 비밀번호를 추출(Extract User credentials)하고 이 유저네임과 비밀번호의 도움으로 UsernamePasswordAuthenticationToken 를 생성한다.
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 = "username";
private String passwordParameter = "password";
private boolean postOnly = true;
public UsernamePasswordAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}
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());
} else {
String username = this.obtainUsername(request);
username = username != null ? username.trim() : "";
String password = this.obtainPassword(request);
password = password != null ? password : "";
// Authentication 인터페이스를 적용하고 있는 Token
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
this.setDetails(request, authRequest);
// AuthenticationManager에게 전달
return this.getAuthenticationManager().authenticate(authRequest);
}
}
//...
}
여기서 잠깐 !
spring security 내부 흐름에서는 2단계에서 인증 객체를 생성할 것이라고 했다. 하지만 token이라는 다른 객체가 보인다. 이 클래스(UsernamePasswordAuthenticationToken)는 내부적으로 AbstractAuthenticationToken을 상속받고, AbstractAuthenticationToken는 Authentication 인터페이스를 적용하고 있다. Authentication은 인터페이스이고, 우리가 인터페이스의 객체를 생성할 수 없으므로 Spring Security Filter는 UsernamePasswordAuthenticationToken 객체를 생성한다. 토큰 객체는 Authenticate()을 호출함으로서 AutenticationManager에게 넘겨주게 된다.
AutenticationManager도 인터페이스이다.
public interface AuthenticationManager {
/**
* Attempts to authenticate the passed {@link Authentication} object, returning a
* fully populated <code>Authentication</code> object (including granted authorities)
* if successful.
* <p>
* An <code>AuthenticationManager</code> must honour the following contract concerning
* exceptions:
* <ul>
* <li>A {@link DisabledException} must be thrown if an account is disabled and the
* <code>AuthenticationManager</code> can test for this state.</li>
* <li>A {@link LockedException} must be thrown if an account is locked and the
* <code>AuthenticationManager</code> can test for account locking.</li>
* <li>A {@link BadCredentialsException} must be thrown if incorrect credentials are
* presented. Whilst the above exceptions are optional, an
* <code>AuthenticationManager</code> must <B>always</B> test credentials.</li>
* </ul>
* Exceptions should be tested for and if applicable thrown in the order expressed
* above (i.e. if an account is disabled or locked, the authentication request is
* immediately rejected and the credentials testing process is not performed). This
* prevents credentials being tested against disabled or locked accounts.
* @param authentication the authentication request object
* @return a fully authenticated object including credentials
* @throws AuthenticationException if authentication fails
*/
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
Spring Security 프레임워크 안에는 ProviderManager라는 클래스가 존재한다. 이것은 AuthenticationManager의 구현체이다. 이 클래스 안에도 Authenticate라는 메소드가 있다.
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
// ...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException ex) {
prepareException(ex, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw ex;
}
catch (AuthenticationException ex) {
lastException = ex;
}
}
if (result == null && this.parent != null) {
// Allow the parent to try.
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
}
catch (ProviderNotFoundException ex) {
// ignore as we will throw below if no other exception occurred prior to
// calling parent and the parent
// may throw ProviderNotFound even though a provider in the child already
// handled the request
}
catch (AuthenticationException ex) {
parentException = ex;
lastException = ex;
}
}
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
// Authentication is complete. Remove credentials and other secret data
// from authentication
((CredentialsContainer) result).eraseCredentials();
}
// If the parent AuthenticationManager was attempted and successful then it
// will publish an AuthenticationSuccessEvent
// This check prevents a duplicate AuthenticationSuccessEvent if the parent
// AuthenticationManager already published it
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
// Parent was null, or didn't authenticate (or throw an exception).
if (lastException == null) {
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}"));
}
// If the parent AuthenticationManager was attempted and failed then it will
// publish an AbstractAuthenticationFailureEvent
// This check prevents a duplicate AbstractAuthenticationFailureEvent if the
// parent AuthenticationManager already published it
if (parentException == null) {
prepareException(lastException, authentication);
}
throw lastException;
}
// ...
}
정리해보자면, Spring Security filter가 인증(token)객체를 생성하는 것은 filter가 유저 자격증명을 추출하는 것(2단계)이다. 토큰 객체가 authenticate 메소드를 호출하는 것은, AuthenticationManager의 구현체인 ProviderManager로 넘어가는 3단계라고 볼 수 있다.
AuthenticationManager
ProviderManager
ProviderManager는 인증 관리자의 구현체이며, 프레임워크 내에 사용가능한 모든 AuthenticationProvider 또는 개발자가 정의한 인증 제공자와 상호작용을 시도한다.
내부에는 반복문이 존재한다. 이 반복문은 모든 적용 가능한 AuthenticationProvider를 반복할 것이고 ProviderManager는 인증 성공이나 실패를 확인할 때 까지 반복 수행한다.
로그인 흐름에 2개의 적용 가능한 AuthenticationProvider가 있다고 가정해보자. 첫 번째 AuthenticationProvider가 결과를 인증 성공으로 판단한다면 프레임워크는 두번째 AuthenticationProvider를 시도하지 않을 것이다.
하지만, 첫번째 AuthenticationProvider가 인증 실패를 반환한다면 이 ProviderManager의 프레임워크는 또 다시 AuthenticationProvider를 시도하게 된다. 그러니 모든 인증과 권한 부여의 로직은 이 AuthenticationProvider의 내부에 있다고 보면 된다.
// ProviderManager의 authenticate() 메소드
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
Iterator var9 = this.getProviders().iterator();
while(var9.hasNext()) {
AuthenticationProvider provider = (AuthenticationProvider)var9.next();
if (provider.supports(toTest)) {
if (logger.isTraceEnabled()) {
Log var10000 = logger;
String var10002 = provider.getClass().getSimpleName();
++currentPosition;
var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
}
try {
result = provider.authenticate(authentication);
if (result != null) {
this.copyDetails(authentication, result);
break;
}
} catch (InternalAuthenticationServiceException | AccountStatusException var14) {
this.prepareException(var14, authentication);
throw var14;
} catch (AuthenticationException var15) {
lastException = var15;
}
}
}
// ...
}
AuthenticationProvider
다음으로 ProviderManager는 Security의 AuthenticationProvider 중 하나를 호출하는데 바로 DaoAuthenticationProvider이다.
DaoAuthenticationProvider는 AbstractUserDetailsAuthenticationProvider를 상속받고 있는 상태이고, 호출한 Authenticate는 AbstractUserDetailsAuthenticationProvider 클래스 내부에 존재한다.
Authenticate 메서드에서 ProviderManager는 요청을 응답하고, 모든 실제 인증 로직이 반환되는 것을 확인할 수 있다.
제일 먼저 username을 불러오려고 시도한다. AbstractUserDetailsAuthenticationProvider 클래스에서 username을 활용해 retrieveUser() 메소드를 호출한다. AbstractUserDetailsAuthenticationProvider 클래스 내부에 존재하는 retrieveUser()는 추상메서드이고, DaoAuthenticationProvider 클래스 내부의 retrieveUser()에서 실질적인 로직이 처리된다. retrieveUser는 UserDetailsManager 또는 UserDetailsService의 도움을 받는다. 여기서는 userDetailsManager 의 구현체 중 하나로부터 도움을 받는데, 그 이름은 InMemoryUserDetailsManager이다.
AbstractUserDetailsAuthenticationProvider
public abstract class AbstractUserDetailsAuthenticationProvider
implements AuthenticationProvider, InitializingBean, MessageSourceAware {
// ..
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
}
// ..
protected abstract UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException;
}
// ..
}
DaoAuthenticationProvider
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
// ...
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
// ..
}
좀 헷갈린다. 다시 정리해보자면....
1. ProviderManager는 AuthenticationProvider 중 하나인 DaoAuthenticationProvider 호출
(DaoAuthenticationProvider는 AbstractUserDetailsAuthenticationProvider를 상속받고 있는 상태)
2. AbstractUserDetailsAuthenticationProvider 내부의 Authenticate() 호출
3. username으로 retrieveUser() 메서드 호출
( AbstractUserDetailsAuthenticationProvider 내부의 retrieveUser는 추상 메서드)
4. DaoAuthenticationProvider에서 retrieveUser() 호출 -> 실제 로직 처리
5. retrieveUser는 UserDetailsManager 또는 UserDetailsService의 도움을 받음. 이 과정에서는 UserDetailsManager의 구현체 중 하나인 InMemoryUserDetailsManager
후아.. 복잡하다. 그러나 한편으로는 고맙다. Spring Security가 해주지 않았더라면, 다 직접 구현했어야 하는 게 아닌가......!
UserDetailsManager
InMemoryUserDetailsManager
우리가 유저 정보를 애플리케이션의 인메모리 내부에 저장하려고 할 때마다 이 클래스 내부로 들어온다. 우리는 유저 자격 증명을 application.properties(yml 일수도)에 저장하고 있다. 우리가 이 속성을 통해 유저 네임과 비밀번호를 저장할 때 마다 유저 정보는 애플리케이션의 인메모리 내부에 저장된다. 그래서 프레임워크가 이 InMemoryUserDetailsManager를 활용하려는 것이다.
InMemoryUserDetailsManager에서는 loadUserByUsername() 메소드를 호출한다. 유저 네임을 기반으로 유저 정보를 불러온다. 이 유저 정보는 우리가 application.properties 내부에 설정한 유저 정보에 기반해 실행 시점에 채워져 있다. 유저 정보가 채워진 다음 DaoAuthenticationProvider의 retrieveUser 메서드가 AbstractUserDetailsAuthenticationProvider의 Authenticate 메소드에 유저 정보를 반환하는 것을 볼 수 있다.
(AbstractUserDetailsAuthenticationProvider의 authenticate() 메소드 안에서 동일한 유저 정보를 additionalAuthenticationChecks() 메소드에 전달하고 있다. 이 메소드의 구현은 DaoAuthenticationProvider 내부에 있다.)
ublic class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {
// ...
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = this.users.get(username.toLowerCase());
if (user == null) {
throw new UsernameNotFoundException(username);
}
return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(),
user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
}
// ...
}
DaoAuthenticationProvider의 additionalAuthenticationChecks()
이 메소드에서 저장소로부터 불러온 유저 정보에 기반해 Password Encoder를 활용하여 UI에서 제공한 비밀번호와 저장소 내부의 비밀번호를 대조하고 있다. Password Encoder가 충족하고 비밀번호가 일치한다면 이 응답은 ProviderManager에 전달된다.
인증이 성공적이라면 프레임워크는 보호된 API URL 웹페이지를 표시하게 된다.
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
우리는 Spring Security의 내부 흐름을 모두 살펴보았다. 이 내용들도 핵심만 추린 것이고, 전체적인 과정을 모두 다루진 않은 것 같다. Spring Security Framework에게 많은 도움을 받고 있다는 생각이 들었다. 앞으로 OAuth 2.0, jwt 등에 대해서도 정리를 해보려한다.
참고
https://product.kyobobook.co.kr/detail/S000201766024
https://hello-judy-world.tistory.com/216
https://spring.io/projects/spring-security
https://www.udemy.com/course/spring-security-6-jwt-oauth2-korean/