본문 바로가기

Tech/Spring

Spring Security 인터페이스를 활용한 Rest API 로그인 인증 구현

개요

 이번 교내 팀 프로젝트에서 Spring Security를 확실하게 짚고 가고자 회원관리를 담당했습니다. Spring Sedurity를 활용해 기본 제공 FORM 로그인이 아닌 REST API로 로그인 인증을 구현했습니다. 이 포스팅을 통해 Security의 인증 핵심 인터페이스를 어떻게 구현했는지 복기하려 합니다. Spring Security 인증 절차 & 핵심 인터페이스 포스팅을 함께 보시는 것을 추천 드립니다.

 

Spring Security 인증 핵심 인터페이스

인증 처리의 핵심 인터페이스 다섯 가지를 소개합니다.

  • AbstractAuthenticationProcessingFilter: 인증 처리의 시작점
  • AuthenticationProvider: 실질적인 인증 처리를 담당
  • Authentication: 인증 절차를 거치게 될 대상이자(이 시점에서는 사용자가 입력한 username과 password를 가짐), 인증이 완료된 대상(이 시점에서는 실제 회원 데이터를 가짐)
  • AuthenticationSuccessHandler: 인증 처리 성공 후처리를 담당
  • AuthenticationFailureHandler: 인증 처리 실패 후처리를 담당

 

1. MemberAuthenticationToken

  • 애플리케이션 내에서 인증 객체로 사용될 AbstractAuthenticationToken을 구현했습니다.
@Getter
public class MemberAuthenticationToken extends AbstractAuthenticationToken {

    private Object principal;
    private String credential;

    private MemberAuthenticationToken(String username, String password) {
        super(null);
        this.principal = username;
        this.credential = password;
        this.setAuthenticated(false);
    }

    private MemberAuthenticationToken(Object principal, String credential) {
        super(null);
        this.principal = principal;
        this.credential = null;
        this.setAuthenticated(true);
    }

    // 인증처리 전
    public static MemberAuthenticationToken unauthenticated(UsernamePasswordLoginDto loginDto) {
        return new MemberAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
    }

    // 인증처리 후
    public static MemberAuthenticationToken authenticated(AuthenticatedMember principal) {
        return new MemberAuthenticationToken(principal, null);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
}

 

2. LoginProcessingFilter

  • AbstractAuthenticationProcessingFilter의 attemptAuthentication 메서드와 successfulAuthentication, unsuccessfulAuthentication 메서드를 구현했습니다.
@Slf4j
public class LoginProcessingFilter extends AbstractAuthenticationProcessingFilter {

    private SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();

    public LoginProcessingFilter() {
        super(new AntPathRequestMatcher("/members/login")); // "/members/login" 요청에 Filter를 적용
    }

    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 1. HTTP 요청 바디 json을 읽어 DTO로 변환
        UsernamePasswordLoginDto usernamePasswordLoginDto = jsonToUsernamePasswordLoginDto(request);

        // 2. 인증처리 전의 Authentication 객체를 생성
        MemberAuthenticationToken authRequest =
                MemberAuthenticationToken.unauthenticated(usernamePasswordLoginDto);

        // 3. AuthenticationManager에게 인증처리를 위임
        return super.getAuthenticationManager().authenticate(authRequest);
    }

    private UsernamePasswordLoginDto jsonToUsernamePasswordLoginDto(HttpServletRequest request) throws IOException {
        ServletInputStream inputStream = request.getInputStream();
        String usernamePasswordJson = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper.readValue(usernamePasswordJson, UsernamePasswordLoginDto.class);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws ServletException, IOException {
        // 1. 비어있는 SecurityContext를 생성
        SecurityContext context = SecurityContextHolder.createEmptyContext();

        // 2. 인증처리 완료된 Authentication 객체를 SecurityContext에 등록
        context.setAuthentication(authResult);

        // 3. Session 등록 및 성공 핸들러 호출
        this.securityContextRepository.saveContext(context, request, response);
        this.getSuccessHandler().onAuthenticationSuccess(request,response,chain,authResult);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        // 1. SecurityContextHolder 비우기
        SecurityContextHolder.clearContext();

        // 2. 실패 핸들러 호출
        this.getFailureHandler().onAuthenticationFailure(request, response, failed);
    }
}

 

3. UsernamePasswordAuthenticationProvider

  • ProviderManager는 자신이 갖고 있는 Provider 객체들의 목록을 뒤지며 해당 Provider에게 Authentication 토큰을 넘겨줄 수 있는지 없는지 여부를 supports 메서드로 확인합니다.
  • supports 메서드 호출 결과 true를 반환하면 authetnicate 메서드가 호출되며, 넘겨 받은 Authentication에 대해 인증 절차를 수행합니다.
@Component
@RequiredArgsConstructor
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {

    private final PasswordEncoder passwordEncoder;
    private final MemberRepository memberRepository;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 1. DB에서 username으로 Member를 찾음
        MemberAuthenticationToken authenticationToken = (MemberAuthenticationToken) authentication;
        String username = String.valueOf(authenticationToken.getPrincipal());

        Member findMember = memberRepository.findByUsername(username).orElseThrow(
                ()-> new UsernameNotFoundException("아이디를 찾을 수 없습니다."));

        // 2. DB와 password가 일치하는지 검증
        String password = authenticationToken.getCredential();
        if (!passwordEncoder.matches(password, findMember.getPassword())) {
            throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
        }

        // 3. 인증 성공 시 Authentication 토큰 생성
        AuthenticatedMember authenticatedMember = new AuthenticatedMember(findMember.getId(), findMember.getNickname(), findMember.getRole());
        return MemberAuthenticationToken.authenticated(authenticatedMember);
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return MemberAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

 

4. CustomAuthenticationSuccessHandler

public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        // 1. 인증된 AuthenticatedMember 객체를 꺼낸다.
        AuthenticatedMember authenticatedMember = (AuthenticatedMember) authentication.getPrincipal();

        LoginSuccessResponseDto loginSuccessResponseDto = LoginSuccessResponseDto.builder()
                .memberId(authenticatedMember.getMemberId())
                .nickname(authenticatedMember.getNickName())
                .role(authenticatedMember.getRole().name())
                .build();

        BaseResponse<LoginSuccessResponseDto> responseDto = BaseResponse.of(HttpStatus.OK, loginSuccessResponseDto);

        // 2. 응답한다.
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(responseDto));
        response.flushBuffer();
    }
}

 

5. CustomAuthenticationFailureHandler

public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        BaseResponse<Boolean> loginFailureResponseDto = BaseResponse.of(HttpStatus.OK, false);

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        response.getWriter().write(objectMapper.writeValueAsString(loginFailureResponseDto));
        response.flushBuffer();
    }
}

 

6. SecurityConfig

커스텀하게 구현한 Filter와 Handler들을 SecurityFilterChain에, Provider는 AuthenticationManager으로 등록하기 위해 Security Configuration 설정파일을 작성합니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    @Order(0)
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .httpBasic(Customizer.withDefaults())
                .formLogin(login -> login.disable()) // 폼로그인 비허용
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/members/login", "/members/register", "/members/verify-username").permitAll()
                        .anyRequest().authenticated())
                .securityContext(securityContext -> new HttpSessionSecurityContextRepository())
                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                        .accessDeniedHandler(new CustomAccessDeniedHandler()));

        return http.build();
    }

    @Bean
    @Order(1)
    public SecurityFilterChain addCustomFilters(HttpSecurity http,
                                                LoginProcessingFilter loginProcessingFilter) throws Exception {
        http
                .addFilterAt(loginProcessingFilter, UsernamePasswordAuthenticationFilter.class); // 로그인 필터 등록

        return http.build();
    }

    @Bean
    public LoginProcessingFilter loginProcessingFilter(AuthenticationManager authenticationManager) {
        LoginProcessingFilter loginProcessingFilter = new LoginProcessingFilter();
        loginProcessingFilter.setAuthenticationManager(authenticationManager);
        loginProcessingFilter.setAuthenticationSuccessHandler(new CustomAuthenticationSuccessHandler());
        loginProcessingFilter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler());
        return loginProcessingFilter;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationProvider authenticationProvider) {
        return new ProviderManager(authenticationProvider);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

 

Github 소스 코드