Skip to content

스프링 시큐리티 다중 로그인 처리

Kim Dae Hwi edited this page Jan 18, 2024 · 2 revisions

개요

이번 Bingterpark 프로젝트를 진행하며, 회원과 관리자 시스템을 서로 다른 도메인으로 분리했습니다. 이로 인해, 하나의 애플리케이션에서 두 도메인의 로그인을 각각 처리할 필요가 생겼습니다.

본 글에서는 여러 로그인 시스템을 한 애플리케이션 내에서 효율적으로 관리하기 위해 적용한 방법들을 정리하고자 합니다.

기본적인 로그인 처리 과정

기본적인 스프링 시큐리티의 로그인 처리 과정은 다음과 같습니다.

  1. 인증 요청: 사용자가 로그인 폼에 자신의 자격 증명(보통 사용자 이름과 비밀번호)을 입력하고 제출합니다.
  2. UsernamePasswordAuthenticationToken 생성: 스프링 시큐리티는 사용자의 자격 증명을 포함하는 UsernamePasswordAuthenticationToken 객체를 생성합니다. 이 토큰은 아직 인증되지 않은 상태입니다.
  3. AuthenticationManager 호출: 인증 요청을 처리하기 위해 AuthenticationManager가 호출됩니다. AuthenticationManager는 여러 AuthenticationProvider를 관리하고, 이들 중 적절한 Provider를 찾아 인증 작업을 위임합니다.
  4. AuthenticationProvider 처리: 선택된 AuthenticationProviderUserDetailsService를 사용하여 데이터베이스나 다른 저장소에서 사용자의 상세 정보를 조회합니다. UserDetailsServiceUserDetails 객체를 반환하는데, 이 객체는 사용자의 정보(아이디, 비밀번호, 권한 등)를 담고 있습니다.
  5. 자격 증명 확인: AuthenticationProviderUserDetails에 포함된 비밀번호와 사용자가 입력한 비밀번호를 비교합니다. 비밀번호가 일치하면 인증이 성공적으로 이루어지고, UsernamePasswordAuthenticationToken은 인증된 상태로 변경됩니다.
  6. 인증 결과 반환 및 저장: 인증된 Authentication 객체(예: UsernamePasswordAuthenticationToken)는 SecurityContextHolder에 저장되어 사용자가 인증되었음을 나타냅니다.

요구사항

이 과정에서 사용되는 컴포넌트(AuthenticationProvider, UserDetailsService 등)들은 Bean으로 등록되어 스프링 프레임워크에서 관리됩니다. 저희는 이 중 UserDetailsService를 implements 하여 DB에서 사용자 정보를 조회해 인증을 처리하고자 하였으며, 두 가지 상황(관리자 로그인, 회원 로그인)에 맞추어 각각의 UserDetailsService를 구현하였습니다.

AdminUserDetailsService

@Service
@RequiredArgsConstructor  
public class AdminUserDetailsService implements UserDetailsService {  
  
    private final AdminRepository adminRepository;  
  
    @Override  
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {  
       Admin admin = adminRepository.findByEmail(email)  
          .orElseThrow(() -> new SecurityCustomException(MemberErrorCode.ADMIN_NOT_FOUND));  
       if (admin.isLocked())  
          throw new SecurityCustomException(MemberErrorCode.NOT_ACTIVE_ADMIN);  
       if (admin.isDeleted())  
          throw new SecurityCustomException(MemberErrorCode.ADMIN_ALREADY_DELETED);  
       return UserDetailsImpl.from(admin);  
    }  
}

MemberUserDetailsService

@Service
@RequiredArgsConstructor  
public class MemberUserDetailsService implements UserDetailsService {  
  
    private final MemberRepository memberRepository;  
  
    @Override  
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {  
       Member member = memberRepository.findByEmail(email)  
          .orElseThrow(() -> new SecurityCustomException(MemberErrorCode.MEMBER_NOT_FOUND));  
       if (member.isDeleted())  
          throw new SecurityCustomException(MemberErrorCode.MEMBER_ALREADY_DELETED);  
       return UserDetailsImpl.from(member);  
    }  
}

여기서 한 가지 문제는 UserDetailsService는 Bean으로 등록되어 관리되기 때문에, 상황에 따라 다른 UserDetailsService를 적용하기가 어렵다는 것이었습니다. 따라서 상황에 따라 다른 UserDetailsService 구현체를 사용할 방법이 필요했습니다.

해결: AuthenticationProvider와 @Qualifier 사용

먼저, 인증에 사용되는 AuthenticationProvider를 직접 구현하여 사용하는 방법을 선택하였습니다. 위의 로그인 처리과정 4~5번을 보면, AuthenticationProvider가 적절한 UserDetailsService를 사용하여 인증에 필요한 사용자의 정보를 가져온다는 것을 알 수 있습니다.

따라서 "AuthenticationProvider를 직접 구현하여 상황에 맞는 UserDetailsService를 주입해주자"는 생각을 하게 되었습니다.

구현 코드

@Service  
@Qualifier("adminUserDetailsService")  
public class AdminUserDetailsService implements UserDetailsService {  
    // 구현 내용  
}  
  
@Service  
@Qualifier("memberUserDetailsService")  
public class MemberUserDetailsService implements UserDetailsService {  
    // 구현 내용  
}
@Component  
public class CustomAuthenticationProvider implements AuthenticationProvider {  
  
    private final PasswordEncoder passwordEncoder;  
    private final AdminUserDetailsService adminUserDetailsService;  
    private final MemberUserDetailsService memberUserDetailsService;  
  
    public CustomAuthenticationProvider(  
       PasswordEncoder passwordEncoder,  
       @Qualifier("adminUserDetailsService") AdminUserDetailsService adminUserDetailsService,  
       @Qualifier("memberUserDetailsService") MemberUserDetailsService memberUserDetailsService) {  
       this.passwordEncoder = passwordEncoder;  
       this.adminUserDetailsService = adminUserDetailsService;  
       this.memberUserDetailsService = memberUserDetailsService;  
    }  
  
    @Override  
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {  
       String email = authentication.getName();  
       String password = authentication.getCredentials().toString();  
       Role accountType = (Role)authentication.getDetails();  
  
       UserDetailsImpl userDetails;  
       if (accountType.equals(Role.ROLE_USER)) {  
          userDetails = (UserDetailsImpl)memberUserDetailsService.loadUserByUsername(email);  
       } else {  
          userDetails = (UserDetailsImpl)adminUserDetailsService.loadUserByUsername(email);  
       }  
  
       // 기타 검증
  
       if (!passwordEncoder.matches(password, userDetails.getPassword())) {  
          throw new SecurityCustomException(MemberErrorCode.PASSWORD_NOT_MATCHED);  
       }  
  
       return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());  
    }  
  
    ...
}

위의 코드처럼 CustomAuthenticationProvider를 직접 구현함으로써, 인증 객체에 포함된 Role을 기준으로 서로 다른 UserDetailsService를 선택적으로 주입하였습니다. 이 과정에서 @Qualifier 어노테이션을 활용하여 각 서비스의 Bean을 명확히 구분했습니다.

개선: SecurityFilterChain의 분리

회원과 관리자 로그인을 별도로 처리하는 기존 방식은 효과적이지만, 매 요청에서 적절한 UserDetailsService를 선택하기 위해 추가적인 분기문이 필요하다는 단점이 있습니다. 이러한 분기 처리 방식은 객체지향 원칙 중 하나인 개방-폐쇄 원칙(OCP, Open-Closed Principle)에 부합하지 않기 때문에, 이를 개선할 필요성을 느끼게 되었습니다.

시큐리티에 대해 조금 더 공부하다보니 엔드포인트별로 필터체인을 분리할 수 있다는 것을 알게되었고, 다음과 같이 필터체인을 분리하여 상황에 맞는 UserDetailsService를 주입하였습니다.

@Configuration  
public class SecurityConfig {  
  
    @Bean  
    public SecurityFilterChain memberFilterChain(HttpSecurity http) throws Exception {  
       http  
          .antMatcher("/api/*/members/**") // 멤버 API 경로에 대한 필터 체인 적용  
          .authorizeRequests(auth -> auth
             .anyRequest().hasRole("USER") // 멤버 역할을 가진 사용자만 접근 허용  
          )
          .userDetailsService(new AdminUserDetailsService(adminRepository))
			// 기타 설정 생략
       return http.build();  
    }  
  
    @Bean  
    public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception {  
       http  
          .antMatcher("/api/*/admin/**") // 관리자 API 경로에 대한 필터 체인 적용  
          .authorizeRequests(authz -> authz  
             .anyRequest().hasRole("ADMIN") // 관리자 역할을 가진 사용자만 접근 허용  
          )
          .userDetailsService(new MemberUserDetailsService(memberRepository))
			// 기타 설정 생략 
       return http.build();  
    }  
}