이 포스팅에서는 스프링 부트 3.2.2 버전을 사용하고, 스프링 시큐리티 6.2.1 버전을 사용합니다.
저번 포스팅에 이어, 이번에는 시큐리티 필터를 직접 커스터마이징 해보겠습니다.
RESTFUL API 기반의 로그인을 구현할것이 때문에, Json을 처리할 수 있는 필터를 구현해보겠습니다.
public class JsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = "/login"; // /login/oauth2/ + ????? 로 오는 요청을 처리할 것이다
private static final String HTTP_METHOD = "POST"; //HTTP 메서드의 방식은 POST 이다.
private static final String CONTENT_TYPE = "application/json";//json 타입의 데이터로만 로그인을 진행한다.
private final ObjectMapper objectMapper;
private static final String USERNAME_KEY="email";
private static final String PASSWORD_KEY="password";
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); //=> /login 의 요청에, POST로 온 요청에 매칭
public JsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 위에서 설정한 /oauth2/login/* 의 요청에, GET으로 온 요청을 처리하기 위해 설정
this.objectMapper = objectMapper;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE) ) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
String username = usernamePasswordMap.get(USERNAME_KEY);
String password = usernamePasswordMap.get(PASSWORD_KEY);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);//principal 과 credentials 전달
return this.getAuthenticationManager().authenticate(authRequest);
}
}
DEFAULT_LOGIN_REQUEST_URL
, HTTP_METHOD
, CONTENT_TYPE
를 통해 "/login"으로 들어오는 "Post"인 "JSON"형식의 요청에 대해서만 작동하도록 설정했습니다.attemptAuthentication
메서드UsernamePasswordAuthenticationToken
을 사용했습니다. username과 password를 사용하여 로그인하는 전략은 폼로그인과 똑같기 때문에 굳이 따로 구현하지 않고 기존에 있는걸 사용하였습니다.authenticationManager
의 authenticate
메서드를 실행했습니다. 여기서 사용되는 AuthenticationManager
는 ProviderManager
입니다. 이후 SecurityConfig
파일에서 설정합니다.@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
private final ObjectMapper objectMapper;
// 특정 HTTP 요청에 대한 웹 기반 보안 구성
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http .csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.addFilterAfter(jsonUsernamePasswordLoginFilter(), LogoutFilter.class) // 추가 : 커스터마이징 된 필터를 SpringSecurityFilterChain에 등록
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/user/signup", "/", "/login", "/album/init").permitAll()
.anyRequest().authenticated())
// .formLogin(formLogin -> formLogin
// .loginPage("/login")
// .defaultSuccessUrl("/home"))
.logout((logout) -> logout
.logoutSuccessUrl("/login")
.invalidateHttpSession(true))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
// 인증 관리자 관련 설정
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return daoAuthenticationProvider;
}
@Bean
public static PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {//AuthenticationManager 등록
DaoAuthenticationProvider provider = daoAuthenticationProvider();//DaoAuthenticationProvider 사용
provider.setPasswordEncoder(passwordEncoder());//PasswordEncoder로는 PasswordEncoderFactories.createDelegatingPasswordEncoder() 사용
return new ProviderManager(provider);
}
@Bean
public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter() throws Exception {
JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter = new JsonUsernamePasswordAuthenticationFilter(objectMapper);
jsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
return jsonUsernamePasswordLoginFilter;
}
}
앞선 포스팅에서 작성한 코드에 추가하였습니다.
SpringSecurityFilterChain
에는 커스텀한 필터가 존재하지 않으므로, 필터체인의 http 설정에 .addFilterAfter(jsonUsernamePasswordLoginFilter(), LogoutFilter.class)
메서드를 통해 Logout필터 이후에 커스텀 필터를 추가해줍니다.AuthenticationProvider
에 등록해줍니다. 폼 로그인 방식과 동일하게 DaoAuthenticationProvider
를 사용합니다.AuthemticationManager
는 폼 로그인과 동일한 ProviderManager
를 사용합니다.JsonUsernamePasswordAuthenticationFilter
을 빈으로 등록해줍니다. 이때 AuthenticationManager
도 함께 등록해주지 않으면 오류가 발생합니다.이제 전에 만들어 두었던 UserDetails를 반환하는 UserDetailsService 를 상속받은 클래스를 만들어 보겠습니다.
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UsersRepository usersRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Users users = usersRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException(email));
return new UserDetailsImpl(users);
}
}
DB에서 email에 해당하는 유저를 찾아 반환해줍니다.
비밀 번호 검증은 DaoAuthenticationProvider
에서 해줍니다. 더 정확히는 AbstractUserDetailsAuthenticationProvider
에서 진행합니다.
additionalAuthenticationChecks
도 retriveUser와 마찬가지로 추상 메서드이므로 구현 클래스에게 처리를 맡깁니다.
구현클래스인 DaoAuthenticationProvider
에서 구현 되어있는 것을 아래 코드에서 볼 수 있습니다.
비밀번호 (Credential)의 일치 여부를 판단합니다.
userDetails
는 UserDetailsService
에서 만들어준 User 객체이고, 이것의 password 와JsonUsernamePasswordAuthenticationFilter
(정확히는 AbstractAuthenticationProcessingFilter)에서 Request의 정보를 통해 인자로 전달해준 Authentication
객체(여기서는 UsernamePasswordAuthenticationToken)의 Credentials (우리는 password를 넣어주었다)를 비교합니다.
그렇기 때문에 저희는 DB에서 유저 정보만 조회해서 반환만하면 됩니다.
이제 성공 처리와 실패 처리를 할 Handler을 구현해보겠습니다.
@Slf4j
public class LoginSuccessJWTProvideHandler extends SimpleUrlAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
log.info( "로그인 성공. JWT 발급. username: {}" ,userDetails.getUsername());
response.getWriter().write("success");
}
}
SimpleUrlAuthenticationSuccessHandler
을 상속받아, 로그인 성공시 JWT를 발급하도록 합니다. (아직 JWT 발급 로직 미포함) 또한 로그를 출력하도록 합니다.
@Slf4j
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //401 인증 실패
response.getWriter().write("fail");
log.info("로그인 실패");
}
}
SimpleUrlAuthenticationFailureHandler
을 상속받은 핸들러입니다.
상태 코드는 인증을 실패하였다는 의미로, 401 Unauthorized
를 반환하도록 하였습니다.
이제 이 핸들러를 설정에 추가해주도록 하겠습니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
private final ObjectMapper objectMapper;
// 특정 HTTP 요청에 대한 웹 기반 보안 구성
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http .csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.addFilterAfter(jsonUsernamePasswordLoginFilter(), LogoutFilter.class) // 추가 : 커스터마이징 된 필터를 SpringSecurityFilterChain에 등록
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/user/signup", "/", "/login", "/album/init").permitAll()
.anyRequest().authenticated())
// .formLogin(formLogin -> formLogin
// .loginPage("/login")
// .defaultSuccessUrl("/home"))
.logout((logout) -> logout
.logoutSuccessUrl("/login")
.invalidateHttpSession(true))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
// 인증 관리자 관련 설정
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return daoAuthenticationProvider;
}
@Bean
public static PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {//2 - AuthenticationManager 등록
DaoAuthenticationProvider provider = daoAuthenticationProvider();//DaoAuthenticationProvider 사용
return new ProviderManager(provider);
}
@Bean
public LoginSuccessJWTProvideHandler loginSuccessJWTProvideHandler(){
return new LoginSuccessJWTProvideHandler();
}
@Bean
public LoginFailureHandler loginFailureHandler(){
return new LoginFailureHandler();
}
@Bean
public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter() throws Exception {
JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter = new JsonUsernamePasswordAuthenticationFilter(objectMapper);
jsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
jsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessJWTProvideHandler());
jsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
return jsonUsernamePasswordLoginFilter;
}
}
다음 포스팅에서는 로그인 성공시 JWT를 발급하고, 인증이 필요한 요청에 대해서 AccessToken을 가지고 인증을 수행하는 코드를 작성해보겠습니다.
출처 및 참고