Spring Security를 이용한 로그인 인증 구현
지난 글을 마지막으로 Spring Security의 기초, 인증 및 인가까지 모두 알아보았습니다. 이번 글에서는 다루었던 개념을 활용하여 실제로 인증을 구현해보겠습니다. 참고한 자료는 다음과 같습니다.
요구 사항
먼저 이번 글을 통해 달성하고자하는 목표를 먼저 정리해보겠습니다.
- 사용자의 인증 요청에 대해 Spring Security Filter로 처리한다.
- 인증 정보는 이메일과 비밀번호를 사용한다.
- 인증에 성공하는 경우 JWT를 발급한다.
- 인증에 실패하는 경우 보안 강화를 위해 실패한 이유를 간단히 응답하고 자세한 내용은 로그로 남긴다.
의존성 추가
우선 Spring Security와 JWT를 사용하기 위해 의존성을 추가합니다.
dependencies {
// Spring MVC
implementation 'org.springframework.boot:spring-boot-starter-web'
// Spring Security
implementation "org.springframework.boot:spring-boot-starter-security"
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}
AbstractAuthenticationProcessingFilter 살펴보기
브라우저 환경에서 HTTP 기반의 인증 요청을 처리하기 위해선 AbstractAuthenticationProcessingFilter
를 사용하면 됩니다.
구현해야할 것들
AbstractAuthenticationProcessingFilter를 읽어보면 클래스를 올바르게 사용하기 위해서 어떤 것을 해야하는지 모두 나와있습니다.
AbstractAuthenticationProcessingFilter는 인증 요청 토큰을 처리하기 위해서 AuthenticationManager가 필요합니다. 또한, 어떤 요청에 대하여 필터가 동작할 것인지에 대해서 RequestMatcher를 정의해야합니다. 또한, abstract 메서드로 정의되어있는 실제 인증 처리 로직을 구현해야합니다.
또한, 인증을 성공하거나 실패했을 때 이를 처리하는 핸들러도 추가로 구현해야합니다. 기본적으로 인증에 성공하면 URL로 리다이렉트 하기 때문에 우리가 원하는 JWT 응답을 추가할 수 없습니다. 또한, 인증에 실패할 경우에도 클라이언트에게 401 코드만 응답할 뿐 인증이 왜 실패했는지 알려줄 수 없습니다.
이메일 및 비밀번호를 이용한 인증 필터 구현하기
우리가 구현해야할 것을 정리하면 다음과 같습니다.
- 생성자를 통한 AuthenticationManager와 RequestMatcher 설정
- AbstractAuthenticationProcessingFilter의 인증 부분 구현
- 인증 성공 시 응답으로 JWT를 전달하는 AuthenticationSuccessHandler
- 인증 실패 시 응답으로 간단한 이유를 전달하고 상세한 내용을 로깅하는 AuthenticationFailureHandler
이를 바탕으로 새로운 인증 필터 LoginWithEmailFilter
를 구현해보겠습니다.
생성자 구현
AbstractAuthenticationProcessingFilter는 네 가지 생성자를 제공합니다.
setter의 사용 없이 생성자에서 바로 AuthenticationManager와 RequestMatcher를 설정하고 싶기 때문에 마지막 생성자를 사용하겠습니다.
인증 부분 구현
인증 부분의 경우에도 어떤 작업을 수행해야하는지 모두 정의되어 있습니다.
요약하면, 총 세 가지 상황을 처리할 수 있도록 구현해야합니다.
- 인증 성공 시: 인증 토큰을 반환한다.
- 인증 실패 시: AuthenticationException을 던진다.
- 인증 계속 진행: null을 반환한다.
이번 글에서는 이메일 및 비밀번호 인증은 해당 필터에서만 처리할 예정입니다. 따라서 다른 필터가 필요한 경우는 인증 실패로 간주하고 예외를 던지겠습니다.
위 내용을 바탕으로 인증 부분을 구현하면 다음과 같습니다.
@Override
public Authentication attemptAuthentication(
final HttpServletRequest request,
final HttpServletResponse response
) throws AuthenticationException, IOException, ServletException {
final LoginWithEmail body = objectMapper.readValue(request.getInputStream(), LoginWithEmail.class);
final UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(body.email(), body.password());
return getAuthenticationManager().authenticate(authenticationToken);
}
여기서 LoginWithEmailFilter가 인증을 직접 수행하지 않고 AuthenticationManager에게 위임하는 것이 핵심입니다. 이에 관해서는 이전에 작성한 Spring Security 인증 흐름 혹은 공식 문서를 참고해주세요.
또한, 이메일과 비밀번호를 담을 Authentication 구현체는 Spring Security에서 제공하는 UsernamePasswordAuthenticationToken를 사용하였습니다.
따라서 해당 Authentication을 처리할 수 있는 AuthenticationProvider를 다음과 같이 정의할 수 있습니다.
@Component
@RequiredArgsConstructor
public class LoginWithEmailAuthenticationProvider implements AuthenticationProvider {
private final Map<String, Member> inMemoryMemberRepository;
@Override
public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
final String email = authentication.getName();
final String password = authentication.getCredentials().toString();
final Member member = inMemoryMemberRepository.get(email);
if (member == null) {
throw new UsernameNotFoundException("이메일에 해당하는 회원이 존재하지 않습니다.");
}
if (!member.password().equals(password)) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
}
final List<SimpleGrantedAuthority> grantedAuthorities = member.roles().stream().map(SimpleGrantedAuthority::new).toList();
return new UsernamePasswordAuthenticationToken(email, null, grantedAuthorities);
}
@Override
public boolean supports(final Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
이메일 및 비밀번호 인증의 실질적인 처리는 LoginWithEmailAuthenticationProvider가 처리하게 되며 요구사항에서 정의한 대로 예외 케이스까지 핸들링하여 AuthenticationException의 구체 클래스를 예외로 던지는 것까지 구현하였습니다.
AuthenticationSuccessHandler 구현
다음으로 인증에 성공했을 때 JWT를 응답할 수 있도록 AuthenticationSuccessHandler를 구현해보겠습니다.
AuthenticationSuccessHandler는 템플릿 메서드 패턴을 사용하여 인증 성공 시 해야할 작업만 구현하면 나머지 필터를 진행할 수 있도록 되어있습니다. 우리가 원하는 것은 인증된 정보로부터 JWT를 생성하여 응답하는 것이므로 다음과 같이 템플릿 메서드 부분을 구현하면 됩니다.
@Override
public void onAuthenticationSuccess(
final HttpServletRequest request,
final HttpServletResponse response,
final Authentication authentication
) throws IOException, ServletException {
final AuthToken token = jwtProvider.createToken(authentication);
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(token));
}
AuthenticationFailureHandler 구현
이번에는 인증에 실패했을 때 간단한 실패 이유를 응답하고 상세한 이유는 로깅하는 AuthenticationFailureHandler를 구현하겠습니다.
AuthenticationFailureHandler는 단순한 함수형 인터페이스로 되어있어 구체 클래스 없이 람다식으로 전달해도 되지만 관리의 용이함을 위해 구현체를 구현하겠습니다.
@Override
public void onAuthenticationFailure(
final HttpServletRequest request,
final HttpServletResponse response,
final AuthenticationException e
) throws IOException, ServletException {
log.error("이메일을 이용한 로그인이 실패했습니다.", e);
final Map<String, String> body = Map.of(
"message", "이메일 및 비밀번호를 이용한 로그인이 실패했습니다."
);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(body));
}
컴포넌트 통합
마지막으로 이메일 및 비밀번호 기반의 필터에 구현한 컴포넌트를 주입하여 완성하면 됩니다.
public LoginWithEmailFilter(
final RequestMatcher requiresAuthenticationRequestMatcher,
final AuthenticationManager authenticationManager,
final AuthenticationSuccessHandler successHandler,
final AuthenticationFailureHandler failureHandler,
final ObjectMapper objectMapper
) {
super(requiresAuthenticationRequestMatcher, authenticationManager);
setAuthenticationSuccessHandler(successHandler);
setAuthenticationFailureHandler(failureHandler);
this.objectMapper = objectMapper; // 역직렬화에 사용
}
Spring Security 구성하기
마지막으로 Spring Security에 필터를 등록하겠습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
final HttpSecurity http,
final AuthenticationConfiguration authenticationConfiguration,
final ObjectMapper objectMapper,
final JwtProvider jwtProvider
) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(it -> it.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new LoginWithEmailFilter(
request -> request.getRequestURI().equals("/login"),
authenticationManager(authenticationConfiguration),
new LoginWithEmailSuccessHandler(objectMapper, jwtProvider),
new LoginWithEmailFailureHandler(objectMapper),
objectMapper
), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(it -> it.anyRequest().authenticated());
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
마무리하며
이번 글에서는 Spring Security의 필터를 이용하여 이메일 및 비밀번호 기반의 인증 처리를 구현하였습니다. 물론 Spring MVC를 이용하여 인증 처리를 구현하는 방법이 더 편하고 이해하기 쉽습니다. 그러나 프로젝트의 규모가 커질 때를 고려하면 Spring Security라는 추상화된 개념에 맞춰서 구현하는 것이 확장성이 있다고 생각합니다. 또한, 다른 사람이 코드를 읽을 때 보다 예상 가능한 코드를 제공해줄 수도 있겠습니다.
이번 글에서 사용한 코드는 아래에서 전체 내용을 확인할 수 있습니다.
댓글남기기