-
Notifications
You must be signed in to change notification settings - Fork 5
스프링 시큐리티 다중 로그인 처리
이번 Bingterpark 프로젝트를 진행하며, 회원과 관리자 시스템을 서로 다른 도메인으로 분리했습니다. 이로 인해, 하나의 애플리케이션에서 두 도메인의 로그인을 각각 처리할 필요가 생겼습니다.
본 글에서는 여러 로그인 시스템을 한 애플리케이션 내에서 효율적으로 관리하기 위해 적용한 방법들을 정리하고자 합니다.
기본적인 스프링 시큐리티의 로그인 처리 과정은 다음과 같습니다.
- 인증 요청: 사용자가 로그인 폼에 자신의 자격 증명(보통 사용자 이름과 비밀번호)을 입력하고 제출합니다.
-
UsernamePasswordAuthenticationToken 생성: 스프링 시큐리티는 사용자의 자격 증명을 포함하는
UsernamePasswordAuthenticationToken
객체를 생성합니다. 이 토큰은 아직 인증되지 않은 상태입니다. -
AuthenticationManager 호출: 인증 요청을 처리하기 위해
AuthenticationManager
가 호출됩니다.AuthenticationManager
는 여러AuthenticationProvider
를 관리하고, 이들 중 적절한Provider
를 찾아 인증 작업을 위임합니다. -
AuthenticationProvider 처리: 선택된
AuthenticationProvider
는UserDetailsService
를 사용하여 데이터베이스나 다른 저장소에서 사용자의 상세 정보를 조회합니다.UserDetailsService
는UserDetails
객체를 반환하는데, 이 객체는 사용자의 정보(아이디, 비밀번호, 권한 등)를 담고 있습니다. -
자격 증명 확인:
AuthenticationProvider
는UserDetails
에 포함된 비밀번호와 사용자가 입력한 비밀번호를 비교합니다. 비밀번호가 일치하면 인증이 성공적으로 이루어지고,UsernamePasswordAuthenticationToken
은 인증된 상태로 변경됩니다. -
인증 결과 반환 및 저장: 인증된
Authentication
객체(예:UsernamePasswordAuthenticationToken
)는SecurityContextHolder
에 저장되어 사용자가 인증되었음을 나타냅니다.
이 과정에서 사용되는 컴포넌트(AuthenticationProvider
, UserDetailsService
등)들은 Bean으로 등록되어 스프링 프레임워크에서 관리됩니다.
저희는 이 중 UserDetailsService
를 implements 하여 DB에서 사용자 정보를 조회해 인증을 처리하고자 하였으며, 두 가지 상황(관리자 로그인, 회원 로그인)에 맞추어 각각의 UserDetailsService를 구현하였습니다.
@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);
}
}
@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를 직접 구현하여 사용하는 방법을 선택하였습니다. 위의 로그인 처리과정 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을 명확히 구분했습니다.
회원과 관리자 로그인을 별도로 처리하는 기존 방식은 효과적이지만, 매 요청에서 적절한 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();
}
}