실전 프로젝트 구성 - ajax

현재까지는 Form Login 방식의 Spring Security 설정을 진행하였는데, Backend 개발과정에서 API는 뻬놓을 수 없는 영역이다. Front와 Back를 서로 구분하여 개발하여 Front-Back 통신 간에 API를 활용하기 때문에 API 개발은 필수이다.

Ajax을 이용한 Spring Security의 인증은 아래와 같은 흐름으로 이루어진다. 보면 알듯이, Form Login과 매우 유사하게 동작하는 확인할 수 있다.

ajax_flow

Config

API 인증 방식 처리를 위해 별도의 설정 클래스로 분리해서 진행하도록 하고, SecurityMatcher을 등록하여 해당 경로로 오는 요청에 대해서는 해당 설정을 따라가도록 한다.

Security Config

@Bean
@Order(0)
public SecurityFilterChain ajaxSecurityFilterChain(HttpSecurity httpSecurity) throws Exception{
    httpSecurity
            .securityMatcher("/api/**")
            .authorizeHttpRequests()
            .anyRequest().authenticated();
    httpSecurity
            .addFilterBefore(ajaxLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class);

    httpSecurity
            .csrf().disable();

    return httpSecurity.build();
}

Components

AjaxAuthenticationFilter

UsernamePasswordAuthenticationFilter 처럼 Ajax 방식을 처리하는 Filter가 요구된다. Ajax을 처리할 수 있는지 여부를 판단하여 Token을 만들어주는 작업을 진행한다.

AjaxAuthenticationToken

public class AjaxAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;

    private Object credentials;

    public AjaxAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        setAuthenticated(false);
    }

    public AjaxAuthenticationToken(Object principal, Object credentials,
                                               Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true); // must use super, as we override
    }

    @Override
    public Object getCredentials() {
        return this.credentials;
    }

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

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        Assert.isTrue(!isAuthenticated,
                "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }

}

AjaxAuthenticationFilter


public class AjaxLoginProcessingFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

    private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;

    private String passwordParameter =SPRING_SECURITY_FORM_PASSWORD_KEY;


    private ObjectMapper objectMapper = new ObjectMapper();

    public AjaxLoginProcessingFilter(){
        super(new AntPathRequestMatcher("/api/login"));;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        //ajax 요청인지 확인
        if(!isAjax(request))
            throw new IllegalStateException(("Authentication is not supported"));
        String username = obtainUsername(request);
        String password = obtainPassword(request);
        if(username.isEmpty() || password.isEmpty())
            throw new IllegalStateException("Username or Password is empty");
        
        //AjaxAuthenticationToken을 만들어서 AuthenticationManager에 등록한다.
        AjaxAuthenticationToken ajaxAuthenticationToken = new AjaxAuthenticationToken(username,password);

        return getAuthenticationManager().authenticate(ajaxAuthenticationToken);
    }

    private boolean isAjax(HttpServletRequest request) {
        if ("XMLHttpRequest".equals(request.getHeader("X-Requested-with")))
            return true;
        return false;
    }

    @Nullable
    protected String obtainPassword(HttpServletRequest request) {
        return request.getParameter(this.passwordParameter);
    }

    @Nullable
    protected String obtainUsername(HttpServletRequest request) {
        return request.getParameter(this.usernameParameter);
    }

    public void setUsernameParameter(String usernameParameter) {
        Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
        this.usernameParameter = usernameParameter;
    }

    public void setPasswordParameter(String passwordParameter) {
        Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
        this.passwordParameter = passwordParameter;
    }


    public final String getUsernameParameter() {
        return this.usernameParameter;
    }

    public final String getPasswordParameter() {
        return this.passwordParameter;
    }
}

만들어준 필터를 등록해주는 설정을 추가한다.

Security Config

@Bean
public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
    return new AjaxLoginProcessingFilter();
}

httpSecurity
    .addFilterBefore(ajaxLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class);

AjaxAuthenticationProvider

Ajax 요청 방식에서 인증을 처리하는 Provider을 생성해주도록 하자

@Component
@RequiredArgsConstructor
public class AjaxAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String loginId = authentication.getName();
        String passWord = (String)authentication.getCredentials();

        AccountContext userDetails = (AccountContext)userDetailsService.loadUserByUsername(loginId);

        if(userDetails == null|| !passwordEncoder.matches(passWord,userDetails.getPassword())){
            throw new BadCredentialsException("BadCredentialException");
        }

        FormWebAuthenticationDetails details = (FormWebAuthenticationDetails)authentication.getDetails();
        String secretKey = details.getSecretKey();

        if(secretKey == null || !secretKey.equals("secret")){
            throw new InsufficientAuthenticationException("secret key error");
        }
        return new AjaxAuthenticationToken(userDetails.getAccount(), null, userDetails.getAuthorities());
    }

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

Provider을 만들어줬으니, AuthenticationManager에 해당 provider을 등록해주는 설정을 추가한다.

Security Config

private final AuthenticationConfiguration authenticationConfiguration;
private final AjaxAuthenticationProvider ajaxAuthenticationProvider;

@Bean
public AuthenticationManager ajaxAuthenticationManager(AuthenticationConfiguration authConfiguration) throws Exception {
    ProviderManager authenticationManager = (ProviderManager)authConfiguration.getAuthenticationManager();
    authenticationManager.getProviders().add(0,ajaxAuthenticationProvider);
    return authenticationManager;
}

@Bean
public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
    AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter();
    ajaxLoginProcessingFilter.setAuthenticationManager(ajaxAuthenticationManager(authenticationConfiguration));
    ajaxLoginProcessingFilter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());  //AbstractAuthenticationProcessingFilter의 기본형이 RequestAttributeSecurityContextRepository인데, 이를 활용할시에는 Session에 Security Context을 저장하는 작업을 진행하지 않게되므로, HttpSessionSecurityContextRepository로 설정하도록 한다.
    return ajaxLoginProcessingFilter;
}

Success Handler & Failure Handler

인증이 성공하거나, 실패했을 때 호출되는 handler들을 정의한다.

AjaxSuccessHandler


@Component
public class AjaxSuccessHandler implements AuthenticationSuccessHandler {
    private ObjectMapper objectMapper = new ObjectMapper();
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        Account account = (Account)authentication.getPrincipal();

        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        objectMapper.writeValue(response.getWriter(),account);
    }
}

AjaxFailureHandler

@Component
public class AjaxFailureHandler implements AuthenticationFailureHandler {
    private ObjectMapper objectMapper = new ObjectMapper();
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        String errorMessage = "Invalid Username or Password";

        if (exception instanceof UsernameNotFoundException) {
            errorMessage = "Invalid Username or Password";
        } else if (exception instanceof InsufficientAuthenticationException) {
            errorMessage = " Invalid Secret Key";
        }

        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        objectMapper.writeValue(response.getWriter(), errorMessage);
    }
}

Security Config


private final AjaxSuccessHandler ajaxSuccessHandler;
private final AjaxFailureHandler ajaxFailureHandler;

public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
    ...
    ajaxLoginProcessingFilter.setAuthenticationSuccessHandler(ajaxSuccessHandler);
    ajaxLoginProcessingFilter.setAuthenticationFailureHandler(ajaxFailureHandler);
    ...
    return ajaxLoginProcessingFilter;
}

AuthenticationEntryPoint & AccessDeniedHandler

ExceptionTranslationFilter에서 인증 예외가 발생항게 되면 AuthenticationException이 발생하게 되면서 RequestCache 처리와 AuthenticationEntrypoint을 호출하게 된다. 인가 예외가 발생했을 경우에는 AccessDeniedException을 발생시켜 AccessDeniedHandler을 호출한다.

AjaxLoginAuthenticationEntryPoint

@Component
public class AjaxLoginAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

AjaxAccessDeniedHandler

@Component
public class AjaxAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access is denied");
    }
}

Securiy Config

private final AjaxLoginAuthenticationEntryPoint ajaxLoginAuthenticationEntryPoint;
private final AjaxAccessDeniedHandler ajaxAccessDeniedHandler;

public SecurityFilterChain securityFilterChain(HttpSecutiry httpSecurity){
    httpSecurity.exceptionHandling()
            .authenticationEntryPoint(ajaxLoginAuthenticationEntryPoint)
            .accessDeniedHandler(ajaxAccessDeniedHandler);
}

DSL Configurer

DSL을 활용해서 Config file을 클래스 하나로 일괄적으로 관리할 수 있다.

AjaxLoginConfigurer

public class AjaxLoginConfigurer<H extends HttpSecurityBuilder<H>> extends AbstractAuthenticationFilterConfigurer<H,AjaxLoginConfigurer<H>, AjaxLoginProcessingFilter> {
    private AuthenticationSuccessHandler successHandler;
    private AuthenticationFailureHandler failureHandler;
    private AuthenticationManager authenticationManager;

    private AuthenticationDetailsSource authencationDetailsSource;

    public AjaxLoginConfigurer() {
        super(new AjaxLoginProcessingFilter(), null);
    }

    @Override
    public void init(H http) throws Exception {
        super.init(http);
    }

    @Override
    public void configure(H http) {

        if(authenticationManager == null){
            authenticationManager = http.getSharedObject(AuthenticationManager.class);
        }
        getAuthenticationFilter().setAuthenticationManager(authenticationManager);
        getAuthenticationFilter().setAuthenticationSuccessHandler(successHandler);
        getAuthenticationFilter().setAuthenticationFailureHandler(failureHandler);
        getAuthenticationFilter().setAuthenticationDetailsSource(authencationDetailsSource);

        SessionAuthenticationStrategy sessionAuthenticationStrategy = http
                .getSharedObject(SessionAuthenticationStrategy.class);
        if (sessionAuthenticationStrategy != null) {
            getAuthenticationFilter().setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
        }
        RememberMeServices rememberMeServices = http
                .getSharedObject(RememberMeServices.class);
        if (rememberMeServices != null) {
            getAuthenticationFilter().setRememberMeServices(rememberMeServices);
        }
        http.setSharedObject(AjaxLoginProcessingFilter.class,getAuthenticationFilter());
        http.addFilterBefore(getAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    public AjaxLoginConfigurer<H> successHandlerAjax(AuthenticationSuccessHandler successHandler) {
        this.successHandler = successHandler;
        return this;
    }

    public AjaxLoginConfigurer<H> failureHandlerAjax(AuthenticationFailureHandler authenticationFailureHandler) {
        this.failureHandler = authenticationFailureHandler;
        return this;
    }

    public AjaxLoginConfigurer<H> setAuthenticationManager(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        return this;
    }

    public AjaxLoginConfigurer<H> setAuthenticationDetailsSource(AuthenticationDetailsSource authenticationDetailsSource) {
        this.authencationDetailsSource = authenticationDetailsSource;
        return this;
    }

    @Override
    protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) {
        return new AntPathRequestMatcher(loginProcessingUrl, "POST");
    }
}

위와 같은 configurer class을 정의하면 아래와 같이 간단하게 처리할 수 있게 된다. Security Class에 표준화된 설정을 처리할 수 있다는 것이 DSL 만의 장점이다.

Security Config

private void customConfigurerAjax(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
            .apply(new AjaxLoginConfigurer<>())
            .successHandlerAjax(ajaxSuccessHandler)
            .failureHandlerAjax(ajaxFailureHandler)
            .setAuthenticationManager(ajaxAuthenticationManager(authenticationConfiguration));
    httpSecurity.formLogin()
            .loginPage("/api/login")
            .loginProcessingUrl("api/login")
            .permitAll();
    }

public SecurityFilterChain ajaxSecurityFilterChain(HttpSecurity httpSecurity) throws Exception{
    customConfigurerAjax(httpSecurity);
    return httpSecurity.build()
}

References

link: inflearn

docs: spring_security

댓글남기기