Java Spring Security with API

떡ol·2023년 5월 27일
0

WebSecurity로 Ajax로 JSON객체를 반환할 수 있는 API를 제작하는 법을 알아보겠습니다.

1. Ajax form 구성하기

우선 화면단에서 Ajax로 데이터를 전송할 Script를 구성해야합니다
_csrf_header, _csrf를 가져와서 보내줘야하므로 meta에 선언하였고, js로 값을 가져와보내는 형식으로 만들었습니다. Ajax가 아닐때는 자동으로 csrf가 설정이 되었지만, 여기서는 xhe.setRequestHeader에 값을 세팅해서 요청을 보내게 됩니다.

<meta id="_csrf" name="_csrf" th:content="${_csrf.token}"/>
<meta id="_csrf_header" name="_csrf_header" th:content="${_csrf.headerName}"/>

<script>
    function formLogin(e) {
        var username = $("input[name='username']").val().trim();
        var password = $("input[name='password']").val().trim();
        var data= { "username": username, "password": password};
        var csrfHeader = $("meta[name='_csrf_header']").attr('content');
        var csrfToken = $("meta[name='_csrf']").attr('content');

        $.ajax({
            type: "POST",
            url: "api/login",
            data: JSON.stringify(data),
            dataType: "json",
            beforeSend : function(xhe){
                xhe.setRequestHeader(csrfHeader, csrfToken);
                xhe.setRequestHeader("X-Requested-With", "XMLHttpRequest");
                xhe.setRequestHeader("Content-type","application/json");
            },
            success: function(data){
                console.log(data);
                window.location="/";
            },
            error: function(xhr, status, error){
                console.log(error);
                window.location = "/login?error=true&exception=" + xhr.responseText;
            }
        });
    }
</script>

이때 Ajax통신이라는것을 서버에 알려줘야하기 때문에 다음과 같이 header에 내용을 하나 추가합니다.

xhe.setRequestHeader("X-Requested-With", "XMLHttpRequest");

2. AuthenticationProcessingFilter

이전 Provider 포스팅 에서는 AuthenticationProvider를 이용하여 인증처리를 진행하였습니다.
AuthenticationProcessingFilter을 통해 이러한 인증처리 과정을 Custom Filtering 하는 것이 가능합니다.
AntPathRequestMatcherUrl을 특정하고, 추가적인 요청에대한 로직을 실행한 후 AuthenticationManager로 인증된 Token을 넘길 수 있습니다.

public class AjaxLoginProcessingFilter extends AbstractAuthenticationProcessingFilter {

    private ObjectMapper objectMapper = new ObjectMapper();

    public AjaxLoginProcessingFilter() {
    	// api/login에 대한 요청만 아래의 인증 과정을 실행해줍니다.
        super(new AntPathRequestMatcher("/api/login"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        if(!isAjax(request)) { // 추가적인 로직 실행
            throw new IllegalStateException("Authentication is not supported");
        }
		//objectMapper를 이용하여 json으로 들어온 요청을 객체로 변환합니다.
        AccountDto accountDto = objectMapper.readValue(request.getReader(), AccountDto.class);
        if(StringUtils.isEmpty(accountDto.getUsername()) || StringUtils.isEmpty(accountDto.getPassword())){
            throw new IllegalAccessError("Username or Password is empty");
        }
		// 그렇게 검사를 마친 객체를 token으로 생성해줍니다 (아래 소스 확인)
        AjaxAuthenticationToken ajaxAuthenticationToken = new AjaxAuthenticationToken(accountDto.getUsername(), accountDto.getPassword());

        return getAuthenticationManager().authenticate(ajaxAuthenticationToken);
    }
	// 위에서 header에 요청이 'XMLHttpRequest' 맞는지를 검토하게 됩니다.
    private boolean isAjax(HttpServletRequest request) {
        if("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))){
            return true;
        }
        return false;
    }
}

요약을 해보면 받은 요청이 /api/login이며, 헤더가 XMLHttpRequest가 맞는지 확인후 인증정보를 셋팅해주면 됩니다. Token 객체는 Spring Security가 사용하는 UsernamePasswordAuthenticationToken 객체를 그대로 복붙하여 하나 만들었습니다만, 따로 추가할게 없어서 이걸 그대로 사용해도 될거같기는 합니다.

//UsernamePasswordAuthenticationToken 파일과 완전 동일 합니다.
public class AjaxAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 520L;
    private final Object principal;
    private Object credentials;

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

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

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

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

    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        } else {
            super.setAuthenticated(false);
        }
    }

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

3. Configure

이제 WebSecurityConfigurerAdapter를 상속받는 설정파일에 셋팅을 해주시면 됩니다.
앞서 말씀드린데로 AuthenticationManagerBean으로 가져와 우리가 만든 Ajax용 Filter에 넣어주면 됩니다.

public class AjaxSecurityConfig extends WebSecurityConfigurerAdapter {
	//중략...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .antMatcher("/api/**")
                .authorizeRequests()
                .anyRequest().authenticated()
        .and()
                .addFilterBefore(ajaxLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
        ;
        // 아래에서 만들어낸 필터를 기존로그인에서 사용하는 AuthenticationFilter앞에서 실행하게 해줍니다. 
        // addFilter(맨뒤에), addFilterAfter(~앞에), addFilterAt(대체) 등 있습니다.
        
    }


    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
        AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter(); // 생성한 필터에...
        ajaxLoginProcessingFilter.setAuthenticationManager(authenticationManagerBean()); // Manager를 가져와...
        ajaxLoginProcessingFilter.setAuthenticationSuccessHandler(ajaxAuthenticationSuccessHandler); 
        ajaxLoginProcessingFilter.setAuthenticationFailureHandler(ajaxAuthenticationFailureHandler);
		// 그 밖에 필요한 헨들러도 구성 가능합니다. http.successHandler(), failureHandler()와 같은 기능입니다. 번외참고

        return ajaxLoginProcessingFilter; //설정을 마칩니다.
    }

4. Provider Custom하기 (번외)

Provider가 무슨 역할을 하는지의 대한 설명은 제외하겠습니다 여기서 확인 가능합니다.

public class AjaxAuthenticationProvider implements AuthenticationProvider{
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Override
    @Transactional
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();

        AccountContext accountContext = (AccountContext) userDetailsService.loadUserByUsername(username);
        if (!passwordEncoder.matches(password,accountContext.getAccount().getPassword())){
            throw new BadCredentialsException("BadCredentialsException");
        }

        AjaxAuthenticationToken ajaxAuthenticationToken = new AjaxAuthenticationToken(
                accountContext.getAccount(),
                null,
                accountContext.getAuthorities()
        );


        return ajaxAuthenticationToken;
    }

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

5. Handler Custom하기 (번외)

Handler의 경우 Ajax통신이므로 Json형식으로 리턴해줘야합니다 ObjectMapper.writeValue()를 이용하면 됩니다.

@Component
public class AjaxAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Autowired
    private ObjectMapper objectMapper; // ObjectMapper는 Spring 빈에 등록되어있습니다. Autowired됩니다. 
    
    @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); //요렇게 선언해주시면 됩니다.
    }
}
@Component
public class AjaxAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        String errorMessage = "Invalid Username or Password";

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

        if(e instanceof BadCredentialsException) errorMessage = "Invalid Username or Password";
        else if(e instanceof InsufficientAuthenticationException) errorMessage = "Invalid Secret Key";

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

6. 인가 Custom하기 (번외)

Anonymous의 경우 EntryPoint를 설정해주고, Authentication의 경우 Denied를 지정해 줍니다.

public class AjaxLoginAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        response.sendError(response.SC_UNAUTHORIZED,"UnAuthorized");
    }
}
public class AjaxAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        response.sendError(response.SC_FORBIDDEN,"Access is denied");
    }
}

Configure, httpExceptionHandler에 추가해주시면 됩니다.

		//생략...
        http
                .exceptionHandling()
                .authenticationEntryPoint(new AjaxLoginAuthenticationEntryPoint())
                .accessDeniedHandler(new AjaxAccessDeniedHandler())
        ;

7. 결론

Java Security API를 만드는 법을 알아봤습니다.
antMatcher()로 API Url의 범위를 지정해주고, 호출 부분은 @RestController로 작성해주시면 됩니다.
단, 계정에 대한 인증 권한을 검사하는 단계는 Filter를 이용해서 새로 구성해야합니다.
Spring과 Security의 틀에서 벗어난게 없어서 이해하는데 어려움은 없었습니다.

profile
하이

0개의 댓글