코드 저장소.

Spring Security의 구조 및 흐름 본문

웹개발/Spring

Spring Security의 구조 및 흐름

slown 2023. 9. 21. 23:43

목차

1.SpringSecurity?

2.SpringSecurity의 인증절차

3.AuthenticationFilter

4.AuthenticationManager

5.AuthenticationProvider

6.UserDetailService,UserDetailsManger

7.SecurityContextHolder & SecurityContext & Authentication

8.공부하고 난 뒤 회고

 

1.SpringSecurity?

스프링 시큐리티는 스프링 기반의 어플리케이션의 보안(인증과 권한)을 담당하는 프레임워크를 말합니다.

2.SpringSecurity의 인증절차

SpringSecurity의 흐름

                                          

SpringSecurity의 인증절차는 다음과 같습니다.

  1. 로그인 페이지에서 클라이언트가 아이디와 패스워드를 입력후  Post로 Http요청을 한다.
  2. 요청이 오면 AuthenticationFilter가 요청을 가로채서 UserNamePasswordAuthenticationToken객체를 생성한다.
  3. 해당 객체를 생성한 후에는 AuthenticationFilter가 AuthenticationManager에게 전달한다.
  4. AuthenticationManager에서 위임받은 해당 인증객체를 처리할 수 있는 AuthenticationProvider를 선택을 한다.
  5. AuthenticationProvider에서 실제 인증을 실행을 하기 시작하면서 사용자의 정보를 가져오기 위해서 UserDetailService를 호출합니다.
  6. UserDetailsService에서 User를 조회하여 인증을 진행한다.
  7. ~10. 인증에 성공이 되면 인증객체(Authentication)를 SecurityContextHolder에 저장을 한다.

3.AuthenticationFilter

AuthenticationFilter의 역할은 인증요청을 가로채서 인증객체를 만든 뒤 AuthenticationManager에 보내는 역할을 합니다. 그 중에서 UserNamePasswordAuthenticatonFilter일반적인 AuthenticationFilter로 로그인폼에서 사용자의 아이디와 비밀번호를 인증하는데 사용되는 필터입니다.

 

해당 필터는 설정 클래스에서 HttpSecurity를 설정할 때 http.formLogin()으로하면 기본적으로  UserNamePasswordAuthenticationFilter를 사용합니다.

@Override
protected void configure(HttpSecurity http) throws Exception {
	http.formLogin();
}

public final class FormLoginConfigurer<H extends HttpSecurityBuilder<H>> extends
		AbstractAuthenticationFilterConfigurer<H, FormLoginConfigurer<H>, UsernamePasswordAuthenticationFilter> {
        
	public FormLoginConfigurer() {
		super(new UsernamePasswordAuthenticationFilter(), null);
		usernameParameter("username");
		passwordParameter("password");
	}
}

 

위의 코드를 보는바와 같이 생성자로 UsernamePasswordAuthenticationFilter()를 사용한다는 것을 알 수가 있다. 그리고 UsernamePasswordAuthenticationFilter의 내부코드를 보면 이러합니다.

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
......

    @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);
    }
}

위의 코드를 보면서 알 수 있는 내용은 

  • 해당 필터는 AbstractAuthenticationProcessingFilter 를 상속 받았다는 점 (로그인을 담당하는 필터)
  • 로그인요청이 오면 Post인지를 확인을 한다.
  • 그리고 입력한 아이디와 비밀번호를 UsernamePasswordAuthenticationToken(인증객체)에 저장을 한다.
  • 저장을 한 뒤 인증객체를 AuthenticationManager로 보낸다.

해당 필터의 흐름은 이러하다.

4.AuthenticationManager

AuthenticationManager는 AuthenticationFilter에서 생성한 인증객체를 위임을 받아서 실제 인증역할을 담당하는 AuthenticationProvider(들)에게 위임하는 역할을 하고 있다.

 

AuthenticationManager의 내부 코드는 이러하다.

public interface AuthenticationManager {
	Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

 

우선은 인터페이스이고 authenticate()메서드 하나로 구성되어 있다. 위의 인증절차에서 보았듯이 AuthenticationManager의 구현체인 ProviderManager에서 실제로 authenticate()메서드가 어떻게 재정의 되었는지 내부코드로 볼 수 있다. 

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {

	private List<AuthenticationProvider> providers = Collections.emptyList();
	
    private AuthenticationManager parent;
    
	@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);
				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;
	}
}

코드의 내용을 보면서 알 수 있는 내용

  • 반복문을 통해서 Providers 리스트에 등록된 AuthenticationProvider들을 반복문을 통해서 돌린다.
  • 각 AuthenticationProvider에게 인증을 실행하고 첫 번째로 성공한 AuthenticationProvider를 반환을 한다.
  • 만약 반복문을 돌렸는데 조건에 일치하지 않은경우에는 Exception을 발생한다. 

그리고 위의 코드를 흐름으로 보면 이러하다.

AuthenticationManager의 흐름

 

5.AuthenticationProvider

AuthenticationProvider는 SpringSecurity에서 실제 사용자를 인증하는 역할을 담당합니다. 그리고 위에서 보다시피 AuthenticaionProvider는 여러가지의 종류가 있는데 그 중에서 대표적인 것이 DaoAuthenticationProvider(데이터베이스에서 인증정보를 가져올 때 사용)이 있습니다. 

 

public interface AuthenticationProvider {

	Authentication authenticate(Authentication authentication) throws AuthenticationException;

	boolean supports(Class<?> authentication);

}

 

AuthentiactionProvider는 인터페이스이고 authenticate() 와 supports()메서드 두가지가 있다. 해당 메서드의 구현체를 보면 이러합니다.

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

	@Override
	@SuppressWarnings("deprecation")
	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"));
		}
	}

	@Override
	protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
			UserDetails user) {
		boolean upgradeEncoding = this.userDetailsPasswordService != null
				&& this.passwordEncoder.upgradeEncoding(user.getPassword());
		if (upgradeEncoding) {
			String presentedPassword = authentication.getCredentials().toString();
			String newPassword = this.passwordEncoder.encode(presentedPassword);
			user = this.userDetailsPasswordService.updatePassword(user, newPassword);
		}
		return super.createSuccessAuthentication(principal, authentication, user);
	}
    ...코드 생략
}

해당 내용을 보면 이러하다.

  • authenticate() 메서드는 인증로직이 포함되어 있고, additionalAuthenticationChecks() 메서드를 통해 Authentication 객체의 credentials(여기서는 password)과 UserDetails를 통해 가져온 credentials 값을 비교하고, 동일 할 경우 createSuccessAuthentication() 메서드를 통해 인증된 UsernamePasswordAuthenticationToken 객체를 만드는 것을 확인할 수 있다.
  • supports() 메서드는 파라미터로 받은 Authentication 객체가 UsernamePasswordAuthenticationToken 클래스를 구현한 객체인지 확인하고 맞을 경우에만 인증 로직을 진행한다.

6.UserDetailService ,UserDetailsManager

UserDetailServiceAuthenticationProvider에서 인증을 수행을 할 때 사용자의 id와 pw를 확인하기 위해서 비밀번호를 PasswordEncoder를 사용해서 디비에 저장된 비밀번호와 일치를 하는지를 수행하면서 함께 사용이 됩니다.

public interface UserDetailsService {

	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

}

 

보시다시피 UserDetailService는 인터페이스이고 사용자의 인증정보를 가져오는 역할을 하는 메서드만 가지고 있고 흔히 우리가 Custom한 클래스에 상속을 받고 Repository를 주입을 해서 디비에 저장된 인증정보를 가져오는 역할을 하고 인증정보가 없는 경우에는 예외를 발생을 한다. 인증에 성공을 하는 경우에는 인증정보가 있는 UserDetails를 리턴한다.

 

UserDetailsManager는 회원의 계정을 생성,수정,삭제를 담당하는 인터페이스이다.

public interface UserDetailsManager extends UserDetailsService {

	void createUser(UserDetails user);

	void updateUser(UserDetails user);

	void deleteUser(String username);

	void changePassword(String oldPassword, String newPassword);

	boolean userExists(String username);

}

 

내부 코드를 보면 UserDetailService를 상속받기 때문에 UserDetailService에 있는 loadUserByUsername를 오버라이딩 할 수 있다. 위의 있는 내용의 상속도를 보면 이러하다.

UserDetailService

SecurityContextHolder & SecurityContext & Authentication

SecurityContextHolder Spring Security에서 현재 사용자의 보안 컨텍스트(SecurityContext)를 저장하고 제공하는 클래스입니다. 이 클래스는 스레드 로컬(ThreadLocal) 저장소를 사용하여 현재 스레드 범위에서 사용자의 보안 정보에 접근할 수 있게 합니다. 

 

해당 코드의 내용을 보면 이러합니다.

public class SecurityContextHolder {
	//코드 생략...
	static {
		initialize();
	}

	private static void initialize() {
		initializeStrategy();
		initializeCount++;
	}
    
    private static void initializeStrategy() {
		if (MODE_PRE_INITIALIZED.equals(strategyName)) {
			Assert.state(strategy != null, "When using " + MODE_PRE_INITIALIZED
					+ ", setContextHolderStrategy must be called with the fully constructed strategy");
			return;
		}
		if (!StringUtils.hasText(strategyName)) {
			// Set default
			strategyName = MODE_THREADLOCAL;
		}
		if (strategyName.equals(MODE_THREADLOCAL)) {
			strategy = new ThreadLocalSecurityContextHolderStrategy();
			return;
		}
		if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
			strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
			return;
		}
		if (strategyName.equals(MODE_GLOBAL)) {
			strategy = new GlobalSecurityContextHolderStrategy();
			return;
		}
		// Try to load a custom strategy
		try {
			Class<?> clazz = Class.forName(strategyName);
			Constructor<?> customStrategy = clazz.getConstructor();
			strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
		}
		catch (Exception ex) {
			ReflectionUtils.handleReflectionException(ex);
		}
	}

	public static void clearContext() {
		strategy.clearContext();
	}
	//코드 생략
}

해당 코드에 볼 수 있는 내용은 이러합니다.

  • SecurityContextHolder는 내부적으로 SecurityContextHolderStrategy라는 객체에게 모든 처리를 위임한다
  • SecurityContextHolderStrategy는 static으로 선언되어 있고 static초기화 블록이 있으니 SecurityContextHolder가 처음으로 실행(로딩)되는 시점에 전략이 정해진다.
  • 기본적으로 정해지는 전략은 ThreadLocalSecurityContextHolderStrategy()를 사용하고 있다.

그리고 ThreadLocalSecurityContextHolderStrategy의 내용을 보면 이렇게 되어있다.

final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {

	private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();

	@Override
	public void clearContext() {
		contextHolder.remove();
	}

	@Override
	public SecurityContext getContext() {
		SecurityContext ctx = contextHolder.get();
		if (ctx == null) {
			ctx = createEmptyContext();
			contextHolder.set(ctx);
		}
		return ctx;
	}

	@Override
	public void setContext(SecurityContext context) {
		Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
		contextHolder.set(context);
	}

	@Override
	public SecurityContext createEmptyContext() {
		return new SecurityContextImpl();
	}

}

 위의 코드를 보면서 알 수 있는 것은 이러합니다.

  • 해당 전략은 ThreadLocal를 사용해서 SecurityContext를 저장을 한다는 것을 알 수가 있습니다. 
  • 그리고 해당 Context를 저장하는 메서드는 createEmptyContext()를 통해서 SecurityContextImpl()메서드를 통해서 SecurityContext를 저장한다.

여기서 ThreadLocal는 해당 쓰레드만 접근할 수 있는 저장소라고 이해를 하시면 됩니다. 

 

SecurtiyContextAuthentication(인증객체)가 저장되는 저장소를 말합니다. SecurityContextHolder 전략에 따라 SecurityContext 저장 방식이 다르고 일반적으로는 ThreadLocal에 저장을 하고 추가적으로 인증이 완료가 되면 세션에 저장을 합니다.

 

SecurityContextHolder, SecurityContext 사용법

// SecurityContext 저장 ( 직접 쓸 일이 많지 않음 )
SecurityContext emptyContext = SecurityContextHolder.createEmptyContext();
emptyContext.setAuthentication(authentication1);
SecurityContextHolder.setContext(emptyContext);

// SecurityContext 조회 ( 가장 많이 쓰는 방식 )
SecurityContextHolder.getContext();

// SecurityContext 초기화 ( 직접 쓸 일이 많지 않음 )
SecurityContextHolder.clearContext();

Authentication은 인증에 사용되는 객체로 인증시 AuthenticationManager의 파라미터 값으로 전달되고 3가지의 요소를 가지고 있습니다.

  • Principal : 사용자를 식별하는 요소
  • Credentials : 비밀번호
  • Authorities : 해당 principal에 부여된 권한( roles)

마지막으로 위의 설명한 내용을 도식화하면 이러하다.

SecurityContextHolder의 구조

 

8.공부하고 난 뒤의 회고

프로젝트를 진행을 하면서 SpringSecurity를 활용해서 회원 로그인/로그아웃을 구현을 했었는데 실제로 Security가 어떤식으로 작동을 하는지를 생각을 해보니깐 제대로 알고 있지 않은것 같아서 어떤 방식으로 작동을 하는지 그리고 내부로직을 보면서 공부를 하고자 시작을 했었는데 책에서 봤었던 구조를 실제로 내부로직과 대조를 해보는데 생각보다 내가 모르는 개념도 나와서 시간이 많이 걸렸던 점도 있었지만 스프링 시큐리티에 대한 전반적인 이해도가 더 높아졌던 기회였다. 물론 공부한 것을 정리를 한 것이지만 아직도 많이 부족하다고 느끼는 부분이 많았었다.

 

Reference

https://velog.io/@dailylifecoding/spring-security-authentication-registry#2-threadlocalsecuritycontextholderstrategy

https://yousrain.tistory.com/13

https://velog.io/@platinouss/Spring-Security-%EC%9D%B8%EC%A6%9D-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98#-authentication

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html

'웹개발 > Spring' 카테고리의 다른 글

Spring Batch??  (0) 2024.05.12
Aop?? (관점지향 프로그래밍)  (0) 2024.05.04
스프링 Di (의존성 주입)  (0) 2024.04.15
스프링 프레임 워크(Spring Frame Work)  (0) 2023.07.22
[Cache] 캐시?  (0) 2023.07.22