서론
우선 지금 진행중인 프로젝트에서 하나의 서비스에 두 개의 엔티티에 대해서 로그인을 진행해야 하는 상황이 생겼습니다.
기존에 작성했던 회원 엔티티에 대해서는 로그인이 제대로 작동했지만 상담사 엔티티는 회원 엔티티가 다르게 작성되어 기존에 작성한 코드로는 상담사 로그인을 할 수 없었습니다.
따라서 여러 방법을 시도하여 방법을 물색했고, 수 많은 에러를 본 후 결국 성공했던 방법을 공유하고자 합니다. 이러한 방법으로 해결은 할 수 있구나 하는 관점에서 봐주시면 감사하겠습니다.
결론 (해결 방법)
- 각각의 엔티티에 대한 @Configuration을 하나의 Security Config에 @Bean으로 등록한다. 이 과정에서 공통적으로 사용하는 PasswordEncoder 같은 bean들은 최상위에 한번만 정의합니다.
- @Order을 사용하여 @Configuration에 대한 우선순위를 부여한다.
- JwtFilter와 같은 각각의 상황에서 공통적을 적용해야 하는 filter가 있는 경우, 해당 필터에 각각의 엔티티에 대해서 Authentication 객체를 얻을 수 있는 메소드를 정의하여 사용한다.
해결을 위해서 시도했던 방법들
방법들을 소개하기 전에 간단하게 UserDetail과 UserDetailsService에 대해 설명하겠습니다.
우선 UserDetail은 Spring Security에서 사용자의 정보를 담는 인터페이스입니다. 해당 인터페이스를 구현하는 것으로 사용자 정보를 정의하고 인증 작업을 수행합니다.
@Getter
@Setter
@AllArgsConstructor
public class MemberPrincipalDetails implements OAuth2User, UserDetails {
private Member member;
private Map<String, Object> attributes;
private String attributeKey;
public MemberPrincipalDetails(Member member) {
this.member = member;
}
@Override
public Map<String, Object> getAttribute(String name) {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + String.valueOf(member.getRole())));
}
@Override
public String getPassword() {
return this.member.getPassword();
}
@Override
public String getUsername() {
return this.member.getName();
}
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return UserDetails.super.isEnabled();
}
public String getName() {
return attributes.get(attributeKey).toString();
}
}
UserDetailsService는 입력 받은 사용자 정보에 해당되는 사용자를 데이터베이스에서 찾아서
앞서 정의한 UserDetail 객체로 변환해주는 역할을 수행합니다.
@RequiredArgsConstructor
@Component
public class MemberDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email).orElseThrow(() -> new MemberNotFoundException("회원 정보가 없습니다."));
return new MemberPrincipalDetails(member);
}
}
우선 다양한 UserDetailsService를 사용해서 사용자 인증을 진행하는 부분에서 문제가 생겼던 부분은 사용자 인증을 진행할 때 Spring Security가 어떤 UserDetailsService를 사용해야 하는지 모른다는 것입니다. UserDetailsService을 구현하는 서비스가 CounselorDetailsServiceImpl, MemberDetailsServiceImpl
가 있었기 때문에 SecurityConfig에서 UserDetailsService로 주입 받아야하는 객체가 어떤 것이 정해주어야 했습니다.
이를 위해서 주입받는 객체가 어떤 것인지 정확하게 알려주기 위해서 @Qualifier 어노테이션을 사용해서 명시해주었지만 여전히 오류가 발생하였습니다.
그래서 어떻게 해결했는데 ?
해결방법은 결론에서 미리 작성했던 것처럼 SecurityConfig 파일에 각각의 Config을 작성해서 @Bean으로 등록하는 것이였습니다. 여기서 공통적으로 사용하는 Bean들은 최상위에만 작성하여 사용하여 의존성 주입 문제를 해결할 수 있었습니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public static PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@RequiredArgsConstructor
@Configuration
@Order(1)
public static class MemberSecurityConfig {
private final MemberDetailsServiceImpl memberDetailsService;
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final JwtTokenProvider jwtTokenProvider;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private static final String[] AUTH_WHITELIST = {
"/api/v1/member/join",
"/api/v1/member/login",
"/api/v1/member/reissue",
"/api/v1/member/oauth-success",
};
@Bean
public DaoAuthenticationProvider memberAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(memberDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public SecurityFilterChain memberFilterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(Customizer.withDefaults())
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeRequests((authorize) -> authorize
.requestMatchers(AUTH_WHITELIST).permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth ->
oauth.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.successHandler(oAuth2SuccessHandler)
)
.exceptionHandling((exceptionHandling) -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler))
.addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@RequiredArgsConstructor
@Configuration
@Order(2)
public static class CounselorSecurityConfig {
private final CounselorDetailsServiceImpl counselorDetailsService;
private final JwtTokenProvider jwtTokenProvider;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private static final String[] WHITELIST = {
"/api/v1/counselor/**",
};
@Bean
public DaoAuthenticationProvider counselorAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(counselorDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public SecurityFilterChain counselorFilterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
.authenticationProvider(counselorAuthenticationProvider())
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(Customizer.withDefaults())
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeRequests((authorize) -> authorize
.requestMatchers(WHITELIST)
.permitAll()
.anyRequest().authenticated())
.exceptionHandling((exceptionHandling) -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler))
.addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOrigins(List.of("<http://localhost:5173>", "<http://localhost>"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
'Spring' 카테고리의 다른 글
@Bean과 @Component의 차이 (0) | 2024.08.25 |
---|---|
기몽수의 FCM 웹 알림 서비스 (0) | 2024.08.17 |
[Security] Spring Security 이모저모 정리 (2) | 2024.07.28 |
스프링내맘대로정리하기 1-2편 (환경 구성, 연관 관계) (0) | 2024.07.28 |
몽수의 Spring Scheduler (0) | 2024.07.28 |
서론
우선 지금 진행중인 프로젝트에서 하나의 서비스에 두 개의 엔티티에 대해서 로그인을 진행해야 하는 상황이 생겼습니다.
기존에 작성했던 회원 엔티티에 대해서는 로그인이 제대로 작동했지만 상담사 엔티티는 회원 엔티티가 다르게 작성되어 기존에 작성한 코드로는 상담사 로그인을 할 수 없었습니다.
따라서 여러 방법을 시도하여 방법을 물색했고, 수 많은 에러를 본 후 결국 성공했던 방법을 공유하고자 합니다. 이러한 방법으로 해결은 할 수 있구나 하는 관점에서 봐주시면 감사하겠습니다.
결론 (해결 방법)
- 각각의 엔티티에 대한 @Configuration을 하나의 Security Config에 @Bean으로 등록한다. 이 과정에서 공통적으로 사용하는 PasswordEncoder 같은 bean들은 최상위에 한번만 정의합니다.
- @Order을 사용하여 @Configuration에 대한 우선순위를 부여한다.
- JwtFilter와 같은 각각의 상황에서 공통적을 적용해야 하는 filter가 있는 경우, 해당 필터에 각각의 엔티티에 대해서 Authentication 객체를 얻을 수 있는 메소드를 정의하여 사용한다.
해결을 위해서 시도했던 방법들
방법들을 소개하기 전에 간단하게 UserDetail과 UserDetailsService에 대해 설명하겠습니다.
우선 UserDetail은 Spring Security에서 사용자의 정보를 담는 인터페이스입니다. 해당 인터페이스를 구현하는 것으로 사용자 정보를 정의하고 인증 작업을 수행합니다.
@Getter
@Setter
@AllArgsConstructor
public class MemberPrincipalDetails implements OAuth2User, UserDetails {
private Member member;
private Map<String, Object> attributes;
private String attributeKey;
public MemberPrincipalDetails(Member member) {
this.member = member;
}
@Override
public Map<String, Object> getAttribute(String name) {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + String.valueOf(member.getRole())));
}
@Override
public String getPassword() {
return this.member.getPassword();
}
@Override
public String getUsername() {
return this.member.getName();
}
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return UserDetails.super.isEnabled();
}
public String getName() {
return attributes.get(attributeKey).toString();
}
}
UserDetailsService는 입력 받은 사용자 정보에 해당되는 사용자를 데이터베이스에서 찾아서
앞서 정의한 UserDetail 객체로 변환해주는 역할을 수행합니다.
@RequiredArgsConstructor
@Component
public class MemberDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email).orElseThrow(() -> new MemberNotFoundException("회원 정보가 없습니다."));
return new MemberPrincipalDetails(member);
}
}
우선 다양한 UserDetailsService를 사용해서 사용자 인증을 진행하는 부분에서 문제가 생겼던 부분은 사용자 인증을 진행할 때 Spring Security가 어떤 UserDetailsService를 사용해야 하는지 모른다는 것입니다. UserDetailsService을 구현하는 서비스가 CounselorDetailsServiceImpl, MemberDetailsServiceImpl
가 있었기 때문에 SecurityConfig에서 UserDetailsService로 주입 받아야하는 객체가 어떤 것이 정해주어야 했습니다.
이를 위해서 주입받는 객체가 어떤 것인지 정확하게 알려주기 위해서 @Qualifier 어노테이션을 사용해서 명시해주었지만 여전히 오류가 발생하였습니다.
그래서 어떻게 해결했는데 ?
해결방법은 결론에서 미리 작성했던 것처럼 SecurityConfig 파일에 각각의 Config을 작성해서 @Bean으로 등록하는 것이였습니다. 여기서 공통적으로 사용하는 Bean들은 최상위에만 작성하여 사용하여 의존성 주입 문제를 해결할 수 있었습니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public static PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@RequiredArgsConstructor
@Configuration
@Order(1)
public static class MemberSecurityConfig {
private final MemberDetailsServiceImpl memberDetailsService;
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final JwtTokenProvider jwtTokenProvider;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private static final String[] AUTH_WHITELIST = {
"/api/v1/member/join",
"/api/v1/member/login",
"/api/v1/member/reissue",
"/api/v1/member/oauth-success",
};
@Bean
public DaoAuthenticationProvider memberAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(memberDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public SecurityFilterChain memberFilterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(Customizer.withDefaults())
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeRequests((authorize) -> authorize
.requestMatchers(AUTH_WHITELIST).permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth ->
oauth.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.successHandler(oAuth2SuccessHandler)
)
.exceptionHandling((exceptionHandling) -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler))
.addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@RequiredArgsConstructor
@Configuration
@Order(2)
public static class CounselorSecurityConfig {
private final CounselorDetailsServiceImpl counselorDetailsService;
private final JwtTokenProvider jwtTokenProvider;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private static final String[] WHITELIST = {
"/api/v1/counselor/**",
};
@Bean
public DaoAuthenticationProvider counselorAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(counselorDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public SecurityFilterChain counselorFilterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
.authenticationProvider(counselorAuthenticationProvider())
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(Customizer.withDefaults())
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeRequests((authorize) -> authorize
.requestMatchers(WHITELIST)
.permitAll()
.anyRequest().authenticated())
.exceptionHandling((exceptionHandling) -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler))
.addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOrigins(List.of("<http://localhost:5173>", "<http://localhost>"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
'Spring' 카테고리의 다른 글
@Bean과 @Component의 차이 (0) | 2024.08.25 |
---|---|
기몽수의 FCM 웹 알림 서비스 (0) | 2024.08.17 |
[Security] Spring Security 이모저모 정리 (2) | 2024.07.28 |
스프링내맘대로정리하기 1-2편 (환경 구성, 연관 관계) (0) | 2024.07.28 |
몽수의 Spring Scheduler (0) | 2024.07.28 |