서론
Spring Security를 활용하여 회원관리 기능을 만들어보며 Spring Security가 기본적으로 제공하는 동작방식에 대해 궁금증이 생겨, 알아보게 되었습니다.
Flow를 그림으로 알아보고, 실제 Code의 흐름에 맞춰서 설명해보았습니다.
이 아래의 Spring Security 구조는 Form Based의 Session을 사용하는 Security 입니다.
저 같은 경우에도 Session 방식을 사용하지 않고, JWT 토큰 방식을 통해 사용하고 있지만, 기본적인 구조는 같기에 공부해보았으니 참고 부탁드립니다.
Spring Security 구조
전체적인 FLOW를 한번 정리해보았습니다.
1번과정
HttpRequest 요청이 Filter로 보내집니다. 그림에서 보듯이 Filter의 DisableEncodeUrlFilter, WebAsyncManagerintegrationFilter ....... 등등 모든 Filter들을 다 거치게 됩니다. 이 과정에서 실제로 로그인 시도와 관련된 Filter는 AbstractAuthenticationProcessingFilter를 Extends한 Filter 로써, abstract 추상화 클래스를 구현한 Filter가 실제 로그인처리를 시도합니다. Spring Security 에서 기본적으로 제공하는 UsernamePasswordAuthenticationFilter는 AbstractAuthenticationProcessingFilter를 extends ( 상속 ) 하고 있습니다. 즉, 해당 Filter가 로그인 처리의 시작점역할을 하고 있다고 생각하면 됩니다. ( 여기서 Implements가 아닌 Abstract를 extends 하고 있는 것을 주의합니다. Interface와 Abstract 차이점은 Abstract는 부모클래스에서 부모클래스에서 정의된 함수들을 Override하지 않고 그대로 사용할 수도 있고, Override해서 사용할 수도 있습니다. Interface와 다르다는점을 인지합니다. )
만약 사용자가 로그인을 하기 위해 Form 기반에서 '/login' 을 시도했다고 가정합니다.
이제 각종 Filter를 거친 후에 사용자는 UsernamePasswordAuthenticationFilter 를 거쳐야합니다.
해당 Filter를 자세히 확인해보겠습니다.
1-1. UsernamePasswordAuthenticationFilter.class ( extends AbstractAuthenticatonProcessingFilter )
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");
@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);
}
//... 생략
}
- 여기서 특히 주목해야할 Method는
- public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) 입니다. 실제로 로그인을 하려는 함수입니다.
- attemptAuthentication 안의 함수를 읽어보면,POST METHOD 형식으로 "username"과 "password"라는 이름으로 Parameter로 받아서 UsernamePasswordAuthenticationToken 토큰을 생성하는 모습을 볼 수 있습니다.
- 위의 FLOW 를 나타낸 이미지에서 UsernamePasswordAuthenticationToken 이 Interface Authentication 을 상속받고 있는 모습을 볼 수 있습니다. ( Authentication 은 Spring Security Context에 Session의 형태로 존재하고 있다는 점을 기억합니다. )
- UsernamePasswordAuthenticationToken 를 통해 이후에 아이디와 패스워드 검증로직을 시작합니다. ( 미리 알려드리자면, DaoAuthenticationPrpvider가 UserDetailService를 호출하여 해당 정보를 통해 진행하므로 반드시 가지고가야하는 정보입니다. 또한 여기서 UsernamePAsswordAuthenticationFilter에서만 UsernamePasswordAuthenticationToken을 등록할 수 있는 것이 아닌, AbstractAuthenticationProcessingFilter를 extends한다면 커스터마이징이 가능합니다. 단, 로그인을 처리하는 필터들이 여러개 있다면, 먼저 작동하는 Filter가 사용됩니다.)
- 이후에 3번과정에서 ProviderManager에서 나올 이야기이지만, 기본적으로 DaoAuthenticationProvider가 Default로 설정되어 있습니다.
그리고 추상(Abstarct)클래스 AbstractAuthenticationProcessingFilter를 확인해보았습니다.
1.2 AbstractAuthenticationProcessingFilter.class
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
//...생략
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authenticationResult);
}
catch (InternalAuthenticationServiceException failed) {
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed);
}
catch (AuthenticationException ex) {
// Authentication failed
unsuccessfulAuthentication(request, response, ex);
}
}
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, uthentication authResult) throws IOException, ServletException {
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authResult);
this.securityContextHolderStrategy.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
//...생략
}
- doFilter Method : 먼저 doFilter함수가 실행되면 attemptAuthentication을 합니다.
- attemptAuthentication은 바로 위 코드의 UsernamePAsswordAuthenticationFilter에 존재합니다.
- 먼저 Filter에서 '/login'을 시도하였을때 authenticate를 실패한다면, 바로 그 순간 return 시킵니다.
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
- 만약 로그인을 성공했다면 this.sessionStrategy.onAuthentication()은 인증 성공 시 세션 관리 전략을 호출하여 세션과 관련된 작업을 처리하는 역할을 합니다.
- SessionStrategy의 onAuthentication 에 대해서 더 알아보기에는 포스팅이 너무 길어질것같아 Session은 넘어가겠습니다. Session에 넣어준다는 것을 확실히 알고있으시면 됩니다.
- 그리고 모든 Filter를 재귀적으로 다 들어가서 확인한뒤에
- successfulAuthentication(request, response, chain, authenticationResult) SecurityContext에 해당 authenticationResult 를 추가시킵니다.
2번과정
Filter를 거쳐서 AuthenticationManager로 이동합니다. 이떄 AuthenticationManager는 실제로 인증역할을 하는 것이 아닌, 'Manager' 라는 이름처럼 Filter에서 받은 UsernamePasswordAuthenticationToken, 그 외에도 Filter에서 CSRF 설정이라든가, RememberMe 설정들이 필터를 거치면서 함께 전송되는데 그 데이터를 기반으로 하여 AuthenticationManager가 적절한 AuthenticationProvider를 찾아서 실제로 인증역할을 수행하게 설정합니다. 여기서 사실상 AuthenticationManager는 Interface이기에 ProviderManager가 AuthenticationManager를 구현하여 AuthenticationProvider를 찾는다고 생각하면 됩니다. AuthenticationManager는 Interface이기에 ProviderManager가 아닌 직접 구현하여서 사용할 수 있습니다. 다만, 여기서 간과해서 안될점은, 오직 ProviderManager에게만 위임하지는 않고, AuthenticationManager, 즉 Parent에게 위임하여 사용할 수도 있습니다. 해당 내용은 아래 코드인 ProviderManager.class 코드에 나와있습니다.
2-1.AuthenticationManager Class
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
//...생략
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);
// 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;
}
// ...생략
}
- ProviderManager가 AuthenticationManager Interface로써 구현하고 있습니다.
- authenticate 함수 안을 보면 for문을 통해 모든 Providers를 순회하고 있습니다.
- 만약 result != null 이면 break; 하는 모습을 통해 하나의 ProviderManager만 작동하도록 설정해놓은 상태입니다.
- 만약 , if (result == null && this.parent != null) { 이라면, AuthenticationProvider가 존재하지않아서 적절하게 수행하지 못한 상황입니다. 이때는 AuthenticationManager 객체를 통해 진행합니다.
- 그래도 기본적으로는 ProviderManager.class에서 진행합니다.
- 왜 위의 형식으로 코드를 만들었을까 생각해보니, Spring Security 개발자들은 특정 ProviderManager를 등록하였다면, 해당 ProviderManager를 무조건적으로 사용하겠다는 의미이니 먼저 코드로 처리하고, AuthenticationManager 는 Basic한 구현으로 할때 사용하라는 의미로 설계한 것 같습니다. 변수이름도 Parent로 설정해두었네요. 개발자들은 ProviderManager를 여러개 개발해놓고, 여러 로그인 과정을 동시에 진행할 수 있도록 처리할 수 있습니다. 하지만, 여러번 진행할시 불필요한 DB ACcess가 있을 수 있으니 관련처리는 필요할 것입니다. 이후에 3번 과정에 나오겠지만, 각 ProviderManager마다 AuthentiationProvider를 설정하여 진행할 수 있기에 가능합니다.
3번과정
해당 AuthenticationManager( 혹은 ProviderManager)가 UsernamePasswordAuthenticationFilter에서 사용할 수 있는 AuthenticationProvider를 찾아서 인증처리를 위임합니다.
아까 UsernamePasswordAuthenticationFilter의 default AuthenticationProvider는 DaoAuthenticationConfigurer 라고 했습니다. 어디서 DaoAuthentication을 AuthenticationProvider로 등록하고 있을지 찾아보았습니다.
3.1 InitializeUserDetailsBeanManagerConfigurer ( extends GlobalAuthenticationConfiguerAdapter )
@Order(InitializeUserDetailsBeanManagerConfigurer.DEFAULT_ORDER)
class InitializeUserDetailsBeanManagerConfigurer extends GlobalAuthenticationConfigurerAdapter {
static final int DEFAULT_ORDER = Ordered.LOWEST_PRECEDENCE - 5000;
private final ApplicationContext context;
/**
* @param context
*/
InitializeUserDetailsBeanManagerConfigurer(ApplicationContext context) {
this.context = context;
}
@Override
public void init(AuthenticationManagerBuilder auth) throws Exception {
auth.apply(new InitializeUserDetailsManagerConfigurer());
}
class InitializeUserDetailsManagerConfigurer extends GlobalAuthenticationConfigurerAdapter {
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
if (auth.isConfigured()) {
return;
}
UserDetailsService userDetailsService = getBeanOrNull(UserDetailsService.class);
if (userDetailsService == null) {
return;
}
PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class);
UserDetailsPasswordService passwordManager = getBeanOrNull(UserDetailsPasswordService.class);
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); //DaoAuthentiationProvider로 등록
provider.setUserDetailsService(userDetailsService);
if (passwordEncoder != null) {
provider.setPasswordEncoder(passwordEncoder);
}
if (passwordManager != null) {
provider.setUserDetailsPasswordService(passwordManager);
}
provider.afterPropertiesSet();
auth.authenticationProvider(provider); //provider를 등록하는 모습
}
/**
* @return a bean of the requested class if there's just a single registered
* component, null otherwise.
*/
private <T> T getBeanOrNull(Class<T> type) {
String[] beanNames = InitializeUserDetailsBeanManagerConfigurer.this.context.getBeanNamesForType(type);
if (beanNames.length != 1) {
return null;
}
return InitializeUserDetailsBeanManagerConfigurer.this.context.getBean(beanNames[0], type);
}
}
}
- 해당 코드의 우선순위는 가장 우선순위가 낮은 것의 - 5000 을 하여 가장 낮은 우선순위보다는 우선순위가 높게 처리합니다. ( 숫자가 낮을수록 우선순위가 높습니다. 이렇게 처리한것은 이미 사용자가 다른 ProviderManager를 통해 인증한경우 해당사항으로 진행하기 위함으로 보입니다. userDetailsService == null 인 코드가 존재하므로 그렇게 생각합니다. )
- 해당 코드를 통해 Default AuthentictionProvider를 확인할 수 있습니다.
- 코드를 보면 기본적으로 DaoAuthenticationProvider로 등록하는 모습을 확인할 수 있습니다.
- 그 외에도, PasswordEncoder, UserDetailsService와 같은 것을 ProviderManager에 등록하는 모습이 보입니다. 4번과정에서 이야기할려고했던 UserDetailsService에 대해서도 알게되었습니다. 해당 코드를 보면 자연스럽게 UserDetailsService도 등록하는 모습이 보입니다.
3.2 DaoAuthenticationProvider.class ( extends AbstractUserDetailsAuthenticationProvider )
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);
}
}
//..생략
}
- DaoAuthenticationProvider에는 여러 기능들이 존재하지만, 우선 UserDetailsService와 연관되어있는 retrieveUser 함수만 살펴보겠습니다.
- 해당 함수는 DaoAuthenticationProvider에 등록되어있는 UserDetailsService의 loadUserByUsername 을 실행시키는 것을 볼 수 있습니다. 이제 UserDetailsService 함수를 확인해보겠습니다.
4번과정, 5번과정, 6번과정 ( 3개의 과정은 서로 깊게 연관되어 있어서 한번에 정리하였습니다. )
우리는 3번과정에서 실제로 ProviderManager에 AuthenticationProvider 설정, PasswordEncoder 설정 하는 코드를 확인할 수 있었습니다.
이제 실제로 DB와 상호작용하여 UserDetails 결과값을 반환하는 UserDetailsService를 확인해보겠습니다.
( 원래 가장 기본적인 Spring Security 에서는 가장 기본적으로 사용할 수 있는 User를 제공하고 있습니다만, 실제로 Spring Security 를 사용하여 Member 관리를 진행할때 username과 password, authorites를 가지고만으로는 진행할 수 없으니 User를 extends 하여 진행해야합니다. User를 Custom 하는 코드는 6번 과정에 나옵니다. )
4-1 UserDetailsService Interface
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
- 우리는 위의 코드를 implements 하여 Customizing 합니다.
UserDetailsService를 구현하기 위해서는 UserDetails 타입으로 Return을 해주어야합니다.
UserDetails 코드를 찾아보았습니다. 그리고 위의 Flow 그림에서도 보면, UserDetails<<Interface>> 이고, User가 해당 Interface를 implements 하는 그림을 볼 수 있습니다. 우리는 User를 implements 합니다. 직접 UserDetails를 implements 하여 진행해도 되지만, 이미 Spring Security 에서 Username과 Password, Authority를 구현하고 있는 User Class가 존재하므로 User Class를 implements 하여 진행합니다.
4-2 UserDetails Interface
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
- UserDetails Interface의 코드입니다.
4-3. User.class
public class User implements UserDetails, CredentialsContainer {
//...생략
public User(String username, String password, Collection<? extends GrantedAuthority> authorities) {
this(username, password, true, true, true, true, authorities);
}
//...생략
}
- UserDetails를 구현하고 있는 User Class 입니다.
- User class를 커스터마이징하여 사용하려면 Constructor를 사용해야하기에 해당 코드를 가져와보았습니다.
4-4. MemberAuthDTO.class
@Log4j2
@Getter
@Setter
@ToString
public class MemberAuthDTO extends User {
private Long member_no;
private String member_id;
private String member_password;
private String member_nickname;
private boolean member_from_social;
/**
* [ 2023-07-11 daeho.kang ]
* Description : It Calls User Constructor
*/
public MemberAuthDTO(Long member_no, String member_id, String member_password, String member_nickname, boolean member_from_social, Collection<? extends GrantedAuthority> authorities){
super(member_id, member_password, authorities);
this.member_no = member_no;
this.member_nickname = member_nickname;
this.member_from_social = member_from_social;
}
}
- super를 통해 User의 생성자를 호출하여 진행합니다.
- User 클래스를 상속하여 코드의 재사용성을 높여줍니다.
4-5. SeminarUserDetailsService.class ( implements UserDetailsService <<Interface>> )
@Service //Bean
@RequiredArgsConstructor
public class SeminarUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> result = memberRepository.findByMember_id(username);
if(result.isEmpty()){
throw new UsernameNotFoundException("UsernameNotFound Exception");
}
Member member = result.get();
MemberAuthDTO memberAuthDTO = new MemberAuthDTO(
member.getMember_no(),
member.getMember_id(),
member.getMember_password(),
member.getMember_nickname(),
member.isMember_from_social(),
member.getMember_role_set()
.stream()
.map(memberRole -> new SimpleGrantedAuthority( "ROLE_"+ memberRole.getRole().getRole_type()))
.collect(Collectors.toSet())
);
return memberAuthDTO;
}
}
- 위의 코드를 통해 UserDetailsService를 활용하여 로그인을 체크합니다.
위의 과정 이후의 과정들 (7번, 8번, 9번, 10번, 11번과정들)
나머지 과정들은 위의 과정들이 종료된 이후에 다시 돌아가면서 종료된 후 남은 작업들을 처리하는 과정일 것입니다.
( 추가로, 나중에는 @PreAuthorize 와 같은 작업들을 통해 실제 권한들을 다스리는 코드들이 존재하는데 해당 과정들은 Interceptor에서 처리합니다. )
마무리
Spring Security의 실제 작동방식을 코드로써 한번 확인해보았습니다.
이렇게 Code로써 직접 확인함으로써 더더욱 몰랐던 점들이 나오고, 분명하지 않았던 개념들이 확실해졌습니다.
관련 정리내용들을 읽어보시고 도움이 되었으면 좋겠습니다.
도움받은 자료들
- https://velog.io/@younghoondoodoom/Spring-Security%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%EC%A1%B0
- https://whwp0913.me/%EC%8A%A4%ED%94%84%EB%A7%81%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-formLogin-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EC%9D%B8%EC%A6%9D%EC%9D%80-%EC%96%B8%EC%A0%9C-%EC%96%B4%EB%94%94%EC%84%9C-%EC%9D%B4%EB%A3%A8%EC%96%B4-%EC%A7%88%EA%B9%8C
- https://1-7171771.tistory.com/80
- https://developjuns.tistory.com/12
- https://ohtaeg.tistory.com/10
- https://velog.io/@hkoo9329/%EC%9E%90%EB%B0%94-extends-implements-%EC%B0%A8%EC%9D%B4
- https://gregor77.github.io/2021/05/18/spring-security-03/
- https://dev-coco.tistory.com/174