
Spring Boot 3.4.1
Spring Security 6.4.2
첨부한 코드는 생략된 부분이 많아 실제로 동작하지 않을 수 있습니다.
JSON 데이터로 유저정보를 받아 인증을 진행하는 필터를 만들어 보았습니다.
시큐리티에서 기본으로 제공되는 Form Login과 흐름이 동일하고 같은 인터페이스와 구현체를 상속 받아 진행하였습니다.

public class JsonAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
@Value("${spring.frontend.base-url}")
private String defaultRedirectUrl;
private final ObjectMapper objectMapper = new ObjectMapper();
public JsonAuthenticationFilter(String defaultFilterProcessesUrl) {
super(defaultFilterProcessesUrl);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
if (!request.getContentType().contains("application/json")) {
throw new AuthenticationServiceException("Authentication content-type not supported: ");
}
storeSessionRedirectUrl(request);
// JSON 데이터 파싱
String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
LoginRequest loginRequest = objectMapper.readValue(body, LoginRequest.class);
// UsernamePasswordAuthenticationToken 생성
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword());
// 인증
return getAuthenticationManager().authenticate(authToken);
}
private void storeSessionRedirectUrl(HttpServletRequest request) {
Cookie redirectCookie = CookieUtil.getCookieFromRequest(request, "redirectUrl").orElse(null);
String redirectUrl = redirectCookie == null ? defaultRedirectUrl : redirectCookie.getValue();
request.getSession().setAttribute("redirectUrl", redirectUrl);
}
}
지정한 URL(RequestMatcher)로 요청이 들어오면 AuthenticationFilter는 가장 앞에서 요청의 데이터를 받습니다.
AbstractAuthenticationProcessingFilter을 상속 받아 커스텀 필터를 구현하였습니다.
.addFilterAt(jsonAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
addFilterAt으로 기본 등록된UsernamePasswordAuthenticationFilter를 교체했습니다.
해당 필터는 JSON 데이터를 파싱하여 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager가 인증을 처리하도록 합니다.
커스텀 로그인 필터를 등록하기 위해 다음 클래스들을 등록해줘야 합니다.
AuthenticationManager
-> 직접 구현하지 않고 시큐리티 폼로그인에서 기본으로 사용하는 ProviderManager를 사용하겠습니다.
AuthenticationProvider
-> ProviderManager는 내부적으로 등록된 AuthenticationProvider을 순회하며 적합한 프로바이더를 찾아 인증을 하게 됩니다. 이 또한 폼로그인에 기본으로 등록되는 DaoAuthenticationProvider를 등록해주겠습니다.
UserDetailsService
-> DB에 저장된 실제 유저의 정보를 UserDetails로 만들어 반환해주어야 합니다. 직접 구현해야 하는 부분이므로 밑에서 자세히 설명하겠습니다.
성공핸들러와 실패핸들러
-> 로그인 성공과 실패 시 동작할 부분입니다. 저는 SimpleUrlAuthenticationSuccessHandler을 상속 받아 등록해주었습니다.
인증이 성공하면 부모 클래스인 AbstractAuthenticationProcessingFilter의 successfulAuthentication()을 호출하여 SecurityContextHolder에 인증정보를 저장하고 성공핸들러를 호출합니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig{
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults()) //TEST
.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
// JSON 인증 필터 등록
.addFilterAt(jsonAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
return http.build();
}
// AuthenticationManager 빈, AuthenticationManager 빈
@Bean
public AuthenticationManager authenticationManager() throws Exception {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(PasswordEncoderFactories.createDelegatingPasswordEncoder());
provider.setUserDetailsService(userDetailsService);
return new ProviderManager(provider);
}
// JsonAuthenticationFilter 빈
@Bean
public JsonAuthenticationFilter jsonAuthenticationFilter() throws Exception {
JsonAuthenticationFilter jsonAuthenticationFilter = new JsonAuthenticationFilter("/login");
jsonAuthenticationFilter.setAuthenticationManager(authenticationManager());
jsonAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler());
jsonAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
return jsonAuthenticationFilter;
}
}
AuthenticationManager와 AuthenticationProvider는 시큐리티에서 제공되는 구현체(ProviderManager, DaoAuthenticationProvider)를 설정/등록만 해주었습니다.
등록된 프로바이더에서 1.UsernamePasswordAuthenticationToken과 UserDetailsService에서 제공한 2.UserDetails의 유저네임(이메일)과 패스워드를 비교하여 인증 성공/실패 여부를 결정합니다.
1.UsernamePasswordAuthenticationToken에는 요청으로 들어온 유저네임/패스워드가 있고,
2.UserDetails에는 DB에서 조회한 유저네임/패스워드가 있다고 볼 수 있습니다.
DaoAuthenticationProvider의 생성자에 Password Encoder로DelegatingPasswordEncoder를 사용하는 걸 볼 수 있습니다.
과거에 기본으로 사용되었던 Plain Text 비밀번호와 현재 권장되는 Bcrypt 패스워드, 향후 더 나은 패스워드 인코더가 도입되었을 때를 대비해 제공되는 패스워드 인코더라고 합니다. 출처
다음으로 2.UserDetails을 제공하는 UserDetailsService를 살펴 보겠습니다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
// DB에서 유저 정보 조회
User user = userRepository.findByEmail(email).orElseThrow(() -> new UsernameNotFoundException(email));
PrincipalDto principal = PrincipalDto.builder()
.id(user.getId())
.username(user.getUsername())
.password(user.getPassword())
.name(null)
.role(user.getRole().name())
.build();
// UserDetails 반환
return new PrincipalDetails(principal);
}
}
UserDetails 인터페이스의 구현체를 반환해야 합니다.
저는 Oauth2.0 인증과 통합하기 위해
UserDetails와OAuth2User를 모두 받아 구현했습니다. 이 글의 내용에서는UserDetails만 구현하시면 됩니다.
SimpleUrlAuthenticationSuccessHandler(성공)와 SimpleUrlAuthenticationFailureHandler(실패)의 구현체를 만들어 등록해 주었습니다.
각각 onAuthenticationSuccess()와 onAuthenticationFailure()를 구현해 주시면 됩니다.
저는 성공 시 JWT토큰을 발급하도록 했습니다.
@RequiredArgsConstructor
public class AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Value("${spring.frontend.base-url}")
private String defaultRedirectUrl;
private static final String REDIRECT_URL = "redirectUrl";
private final JwtProvider jwtProvider;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 로그인 성공 시 브라우저 쿠키 삭제
Cookie cookie = new Cookie(REDIRECT_URL, null);
cookie.setDomain("localhost");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
PrincipalDetails userDetails = (PrincipalDetails) authentication.getPrincipal();
String role = authentication.getAuthorities()
.iterator().next()
.getAuthority();
String accessToken = jwtProvider.generateToken(ACCESS, userDetails.getId(), role, 10 * 60 * 1000L);
String refreshToken = jwtProvider.generateToken(REFRESH, userDetails.getId(), role, 10 * 60 * 1000L);
response.addCookie(jwtProvider.createJwtCookie("access_token", accessToken));
response.addCookie(jwtProvider.createJwtCookie("refresh_token", refreshToken));
String redirectUrl = request.getSession().getAttribute(REDIRECT_URL).toString();
if (!StringUtils.hasText(redirectUrl)) {
redirectUrl = defaultRedirectUrl;
}
Map<String, ? extends Serializable> body = Map.of("message", "success", "redirectUrl", redirectUrl);
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(body));
request.getSession().removeAttribute(REDIRECT_URL);
}
}