[Spring Security] 일반 로그인 & 소셜 로그인 분리 및 Security 로그인 비동기 처리 방법

우리의 서비스는 자체 회원가입을 통해 가입한 유저 로그인, 소셜 계정을 통해 가입된 유저 로그인 2가지 로그인 방식을 지원한다.

처음에는 일반 로그인만 구현해놓고 소셜 로그인은 후반에 적용하게 되었다.

그래서  일반 로그인은 자체 로그인 로직으로, 소셜 로그인은 스프링 시큐리티로 로그인을 구현이 되었다.

그런데 여기서 문제가 발생하게 되었다.

소셜 로그인은 사용자의 권한을 잘 읽어오지만 일반 로그인을 한 회원은 사용자의 권한을 읽어오지 못해

권한 설정을 한 페이지에 접근할수가 없는 문제가 발생하는 것이다.

 

문제 발생이유

기존에는 소셜 로그인은 시큐리티로 처리했지만, 일반 로그인 같은 경우는 아래처럼 요청을 받아 유효성검사를 하고 결과를 반환해주는 식이였다.

하지만 이런식으로 일반 로그인을 처리할 경우에는 시큐리티에서 해당 사용자의 권한을 처리하지 못해 위와 같은 문제가 발생하는 것이였다.

    @PostMapping("/user/login")
    @ResponseBody
    public ResponseEntity<String> loginForm(@RequestBody User user, HttpServletRequest request) {
        User loginUser = userService.selectUserByUserEmail(user.getUser_email());
        if (!userService.loginCheck(user.getUser_pwd(), loginUser)) {
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }
        HttpSession session = request.getSession();
        session.setAttribute(LOGIN_USER, loginUser);
        return new ResponseEntity<>(HttpStatus.OK);
    }

 


일반 로그인(동기 방법)과 소셜 로그인 분리

일반 로그인도 스프링 시큐리티에서 처리할 수 있도록 코드를 추가해줘야 한다.

SecurityConfig - 스프링 시큐리티 설정 클래스
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomUserDetailsService customUserDetailsService;
    private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
    private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /** 폼기반 로그인에 사용.
     * 실제 유효성 검사 및 인증체크 작업을 수행하는 Provider.
		 * 입력받은 사용자 이름과 비밀번호를 기반으로 인증을 수행.
     */
    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(customUserDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }


    @Bean
    protected SecurityFilterChain configure(HttpSecurity http) throws Exception {
        http  
                .csrf().disable().headers().frameOptions().disable()
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/user/searchfilter", "/user/css/**", "/user/img/**", "/user/js/**", "/login", "/user/logout", "/user/cart/**", "/local/**" ,"/user/update-cart" , "/user/detail/**", "/user/review/**", "/findPw", "/changePw", "/changeNewPw", "/user/like/**", "/join/**", "/user/login").permitAll()
                    .antMatchers("/user/mypage", "/confirm/payment", "/order/**", "/payment/**").hasAuthority(Role.USER.name()) // 해당 주소는 USER 권한을 가진 사람만 가능
                    .anyRequest().authenticated()
                .and()
                    .formLogin() // 일반 로그인 설정
                    .loginPage("/login") // 로그인 페이지 경로 설정
                    .loginProcessingUrl("/user/login") // 로그인 요청 경로 설정 (폼 액션에 해당)
                    .usernameParameter("user_email") // 유저네임 파라미터 이름 설정 (기본값: username)
                    .passwordParameter("user_pwd") // 패스워드 파라미터 이름 설정 (기본값: password)
                    .failureHandler(customAuthenticationFailureHandler)
                    .successHandler(customAuthenticationSuccessHandler)
                    .permitAll()
                .and()
                    .logout().invalidateHttpSession(true).logoutSuccessUrl("/")
                .and()
                    .oauth2Login() // OAuth2 로그인 기능에 대한 여러 설정의 진입점
                    .loginPage("/login")
                    .userInfoEndpoint() // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정 담당
                    .userService(customOAuth2UserService); // 소셜 로그인 성공 시 후속 조치를 진행할 OAuth2UserService 인터페이스의 구현체 등록
        return http.build();
    }
}

일반 로그인 스프링 시큐리티가 처리할 수 있도록 하기 위해서는 다음과 같은 준비가 필요하다.

  • UserDetailsService 구현체 클래스
  • SimpleUrlAuthenticationSuccessHandler 구현체 클래스
  • SimpleUrlAuthenticationFailureHandler 구현체 클래스
  • AuthenticationProvider 구현체 Bean 등록 (여기서는 구현체인 DaoAuthenticationProvider 사용)

 

AuthenticationProvider ?

 

스프링 시큐리티에서 비밀번호 검증은 AuthenticationProvider에서 이루어진다.

DaoAuthenticationProvider는 AuthenticationProvider의 구현체로 일반적으로 많이 사용되는 비밀번호 검증 구현체이 이다.

시큐리티의 동기 방식인 formLogin을 사용할 때 주로 사용한다.

 

 

스프링 시큐리티 폼 로그인 흐름

1️⃣ 클라이언트가 로그인 요청을 보내면 AbstractAuthenticationProcessingFilter의 구현체 UsernamePasswordAuthenticationFilter가 이를 가로챈다. (기본 필터 체인에 포함되어 있다.)

2️⃣ UsernamePasswordAuthenticationFilter는 사용자 이름(기본값 : username)과 비밀번호(기본값 : password)를 포함하는 UsernamePasswordAuthenticationToken 객체를 생성.

3️⃣생성된 토큰은 AuthenticationManager에게 전달되며, 여기에 등록된 여러 AuthenticationProvider 중 하나가 선택되어 인증 작업을 처리

4️⃣ 만약 선택된 인증 프로바이더가 DaoAuthenticationProvider라면, 내부적으로 설정된 UserDetailsService의 loadUserByUsername 메소드를 호출하여 UserDetails 객체를 가져온다.

5️⃣ DaoAuthenticationProviderUserDetails 객체와 함께 원본 비밀번호와 저장된 암호화된 비밀번호를 비교하여 검증하는 작업 수행

6️⃣ 검증이 성공하면 인증 정보(Authentication)가 생성되고 SecurityContextHolder에 저장

7️⃣ 검증 성공로그인 성공 처리 핸들러인 SimpleUrlAuthenticationSuccessHandler의 onAuthenticationSuccess 메소드가 호출

8️⃣ 만약 비밀번호 검증이 실패하거나 다른 문제가 발생한다면 예외(Exception)가 발생하고, 이 예외는 로그인 실패 처리 핸들러인 SimpleUrlAuthenticationFailureHandler의 onAuthenticationFailure 메소드로 전달

 

UserDetailsService 구현체

UserDetailsService 인터페이스의 구현체는 loadUserByUsername() 메서드를 재정의하여 사용자의 이름을 입력받아 해당 사용자가 있는지 검증하여 UserDetails를 반환한다.

UserDetails 객체는 사용자에 대한 정보들이 포함되어 있다.

(해당 클래스는 SpringConfig 시큐리티 설정 클래스에서, DaoAuthenticationProvider 설정에 추가해주었다.)

@Service
@Transactional
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String user_email) throws UsernameNotFoundException {
        User user = userRepository.selectUserByUserEmail(user_email, "일반");
        if (user == null) {
            throw new UsernameNotFoundException("해당 사용자를 찾을 수 없습니다.");
        }

        return new org.springframework.security.core.userdetails.User(
                user.getUser_email(),
                user.getUser_pwd(),
                Collections.singleton(new SimpleGrantedAuthority(Role.USER.name()))
        );
    }
}

 

SimpleUrlAuthenticationSuccessHandler 구현체

DaoAuthenticationProvider에서 검증이 성공하면 수행될 로직을 작성한다.

@RequiredArgsConstructor
@Slf4j
@Component
public class CustomAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final UserRepository userRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        String username = ((org.springframework.security.core.userdetails.User) authentication.getPrincipal()).getUsername();
        User user = userRepository.selectUserByUserEmail(username, "일반");
        HttpSession session = request.getSession();
        session.setAttribute(LOGIN_USER, new SessionUser(user));
        log.info("로그인에 성공하였습니다. 이메일 : {}", username);

	response.sendRedirect("/");
    }
}

 

 

SimpleUrlAuthenticationFailureHandler 구현체

DaoAuthenticationProvider에서 검증이 실패하거나 문제가 발생했을 경우 실행될 로직을 작성한다.

@Component
@Slf4j
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException {

        log.info("로그인에 실패했습니다. 메시지 : {}", exception.getMessage());
	super.onAuthenticationFailure(request, response, exception);
    }
}

 


Security 로그인 비동기 처리 방법

위에서는 시큐리티 formLogin 폼 로그인을 통한 방식을 사용했다. formLogin 은 기본적으로 동기 방식이다.

하지만 나는 로그인이 성공하면 비로그인 상태에서 LocalStorage에 담은 장바구니 상품들을 로그인 회원의 장바구니로 합치는 "/user/update-cart"  API 호출이 필요했다.

그렇기 때문에  동기 방식으로 되어있는 스프링 시큐리티 로그인을 비동기 방식으로 변경하는 방법에 대해 설명해보고자 한다.

 

SecurityConfig - 스프링 시큐리티 설정 클래스

비동기 방식은 JSON으로 요청과 응답을 받는다.

JSON의 요청을 받기 위해서는 UsernamePasswordAuthenticationFilter대신 새로운 Filter를 추가해주어야 한다.

UsernamePasswordAuthenticationFilter는AbstractAuthenticationProcessingFilter의 구현체이다.

그렇기 때문에 새로운 Filter를 만들기 위해서는 이 AbstractAuthenticationProcessingFilter를 상속받은 클래스이어야 한다.

 

이전 formLogin 방식과 달라진 점을 보면 다음과 같다.

1️⃣ formLogin 방식(동기)을 disable하고 JSON 요청을 받기위한 만든 AbstractAuthenticationProcessingFilter 구현체를 등록 하였다. ( AuthenticationManager 설정을 필요로 한다.)

2️⃣ AuthenticationManager를 빈 등록한 후 AuthenticationManager에 Provider인 DaoAuthenticationProvider와 사용자 정보를 담고 있는 CustomUserDetailsService, 비밀번호 암호화를 위한 PasswordEncoder를 세팅.

3️⃣ 해당 UsernamePasswordAuthenticationFilter 구현체AuthenticationManager와 핸들러들을 세팅.

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomUserDetailsService customUserDetailsService;
    private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
    private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /** 비동기 요청 - 응답시 사용.
     * 입력된 HTTP 요청의 사용자 자격 증명 값을 추출하기 위한 UsernamePasswordAuthenticationFilter의 구현체
     * 추출한 값으로 UsernamePasswordAuthenticationToken 미인증 상태 객체를 생성해 AuthenticationManager에게 전달.
     */
    @Bean
    public JsonAuthenticationFilter jsonAuthenticationFilter() {
        JsonAuthenticationFilter jsonFilter = new JsonAuthenticationFilter(new ObjectMapper());
        jsonFilter.setAuthenticationManager(authenticationManager());
        jsonFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        jsonFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);
        return jsonFilter;
    }
    
    /**
     * 위임받은 실제 인증 작업 수행.
     * 미인증 UsernamePasswordAuthenticationToken 객체를 받아, 객체에 포함되어 있는 자격 증명이 유효한지 검사.
     */
    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(customUserDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
    }

    @Bean
    protected SecurityFilterChain configure(HttpSecurity http) throws Exception {
        http
                .formLogin().disable() // 비동기 요청을 받기 위해, 기본 방식인 동기 요청 작업 비활성화
                .httpBasic().disable()
                .csrf().disable().headers().frameOptions().disable()
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/user/searchfilter", "/user/css/**", "/user/img/**", "/user/js/**", "/login", "/user/logout", "/user/cart/**", "/local/**" ,"/user/update-cart" , "/user/detail/**", "/user/review/**", "/findPw", "/changePw", "/changeNewPw", "/user/like/**", "/join/**", "/user/login").permitAll()
                    .antMatchers("/user/mypage", "/confirm/payment", "/user/order/**", "/order/**", "/payment/**").hasAuthority(Role.USER.name()) // 해당 주소는 USER 권한을 가진 사람만 가능
                    .anyRequest().authenticated()
                .and()
                    .logout().invalidateHttpSession(true).logoutSuccessUrl("/")
                .and()
                    .oauth2Login() // OAuth2 로그인 설정
                    .defaultSuccessUrl("/", true)
                    .loginPage("/login")
                    .userInfoEndpoint() // 이 체인 다음에 로그인 성공 이후 사용자 정보를 가져오기 위한 설정을 담당하는 객체 등록
                    .userService(customOAuth2UserService); // OAuth2UserService 구현체를 등록

        http.addFilterBefore(jsonAuthenticationFilter(), LogoutFilter.class);
        return http.build();
    }
}

 

시큐리티 비동기 로그인 흐름

1️⃣ 클라이언트가 로그인 요청을 보내면 AbstractAuthenticationProcessingFilter의 구현체 JsonAuthenticationFilter가 이를 가로챈다.

2️⃣ JsonAuthenticationFilter는 사용자 이름(기본값 : username)과 비밀번호(기본값 : password)를 포함하는 UsernamePasswordAuthenticationToken 객체를 생성.

3️⃣생성된 토큰은 AuthenticationManager에게 전달되며, 여기에 등록된 여러 AuthenticationProvider 중 하나가 선택되어 인증 작업을 처리

4️⃣ 선택된 인증 프로바이더 DaoAuthenticationProvider는, 내부적으로 설정된 UserDetailsService의 loadUserByUsername 메소드를 호출하여 UserDetails 객체를 가져온다.

5️⃣ DaoAuthenticationProvider UserDetails 객체와 함께 원본 비밀번호와 저장된 암호화된 비밀번호를 비교하여 검증하는 작업 수행

6️⃣ 검증이 성공하면 인증 정보(Authentication)가 생성되고 SecurityContextHolder에 저장

7️⃣ 검증 성공  로그인 성공 처리 핸들러인 SimpleUrlAuthenticationSuccessHandler의 onAuthenticationSuccess 메소드가 호출

8️⃣ 만약 비밀번호 검증이 실패하거나 다른 문제가 발생한다면 예외(Exception)가 발생하고, 이 예외는 로그인 실패 처리 핸들러인 SimpleUrlAuthenticationFailureHandler의 onAuthenticationFailure 메소드로 전달

 

JsonAuthenticationFilter

UsernamePasswordAuthenticationFilter는 AbstractAuthenticationProcessingFilter의 구현체이다.

따라서 새로운 필터를 만들기위해 AbstractAuthenticationProcessingFilter를 상속받는 새로운 Filter 구현체를 만들었다.

해당 필터는 JSON 요청을 받아 미인증된 UsernamePasswordAuthenticationToken 를 반환한다.

public class JsonAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    private ObjectMapper objectMapper;

    public JsonAuthenticationFilter(ObjectMapper objectMapper) {
        super(new AntPathRequestMatcher("/user/login", "POST")); // 해당 경로 요청을 처리하기 위한 설정
        this.objectMapper = objectMapper;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {
        if(request.getContentType() == null || !request.getContentType().equals("application/json")  ) {
            throw new AuthenticationServiceException("잘못된 요청 형식입니다. = " + request.getContentType());
        }
        Map<String, String> loginForm = objectMapper.readValue(request.getInputStream(), Map.class);

        String userEmail = loginForm.get("user_email");
        String pwd = loginForm.get("user_pwd");

        // 요청 받은 값으로 객체를 생성해 실제 인증 절차를 진행.
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(userEmail, pwd);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

 

UserDetailsService 구현체

위 폼 로그인 방식과 동일하다.

 

SimpleUrlAuthenticationSuccessHandler 구현체

JSON으로 응답을 보내주기 위해 아래처럼 수정하였다.

@RequiredArgsConstructor
@Slf4j
@Component
public class CustomAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final UserRepository userRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        String username = ((org.springframework.security.core.userdetails.User) authentication.getPrincipal()).getUsername();
        User user = userRepository.selectUserByUserEmail(username, "일반");
        HttpSession session = request.getSession();
        session.setAttribute(LOGIN_USER, new SessionUser(user));
        log.info("로그인에 성공하였습니다. 이메일 : {}", username);

        response.setStatus(HttpServletResponse.SC_OK);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=UTF-8");

        JsonObject json = new JsonObject();
        json.addProperty("redirectUrl", "/");

        PrintWriter out = response.getWriter();
        out.print(json);
    }
}

 

SimpleUrlAuthenticationFailureHandler 구현체

마찬가지로 JSON으로 응답을 보내주기 위해 아래처럼 수정하였다.

@Component
@Slf4j
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException {

        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=UTF-8");

        JsonObject json = new JsonObject();
        json.addProperty("message", "아이디 또는 비밀번호가 일치하지 않습니다.");

        PrintWriter out = response.getWriter();
        out.print(json);
        log.info("로그인에 실패했습니다. 메시지 : {}", exception.getMessage());
    }
}

 

비동기 요청 js 코드
$(() => {
    $("#loginFormBtn").on("click", (e) => {
        e.preventDefault();
        axios({
            method: "post",
            url: "/user/login",
            data:   {
                user_email: $("#id").val(),
                user_pwd: $("#pw").val()
            },
            dataType: "json",
            headers: {'Content-Type': 'application/json'}
        }).then((res) => {
            if(res.status == "200"){
                let localCarts = localStorage.getItem('cart') ? JSON.parse(localStorage.getItem('cart')) : [];
                axios({
                    method: "post",
                    url: "/user/update-cart",
                    data: { localCarts },
                    headers: {'Content-Type': 'application/json'}
                }).then(() => {
                    localStorage.removeItem('cart');
                });
                window.location.href = res.data.redirectUrl;
            } else {
                alert(res.data);
            }
        }).catch((error) => {
            alert(error.response.data.message);
        });
    });
});