본문 바로가기

Spring/Spring Security

Ajax로 새로운 Security 설정을 연습해보자

728x90

1. Ajax 는 FormLogin 과는 다르게 비동기 방식의 JS 통신을 말함

2. 타방법의 인증을 선택하려면 기본적으로 Form Login은 사용하지 않는 것이다. 

3. Spring 은 기본적으로 Form Login 을 사용하고 있고, Form Login을 위한 설정들이 박혀 있기 때문에, 그 것을 사용하지 않으려면 Filter, Token, Provider 등을 구현해야 한다. 

4. fromLogin 은 disable 후 사용하는게 좋으나, Session 저장 관련해서도 같이 끄기 때문에 SCHoler 등에 저장하는 것은 직접 따로 구현해봐야 할 듯.

 

=================== 인증을 위한 객체들, Ajax 와는 크게 무관. 하지만 기본적으로 필요 =============

1. Account

@Getter
@Entity
@Slf4j
@NoArgsConstructor
public class Account {

    @Id
    @GeneratedValue
    @Column(name = "account_id")
    private Long id;

    private String username;

    private String password;

    private String email;

    private String age;

    private String role;

    public Account(String username, String password, String email, String age, String role) {
        checkWrongParams(username, password, email, age, role);

        this.username = username;
        this.password = password;
        this.email = email;
        this.age = age;
        this.role = role;
    }

    public void checkWrongParams(String ... strs) {

        boolean isWrongParam = Arrays.stream(strs).anyMatch((str)->{
//            System.out.println("str = " + str);
            return !StringUtils.hasText(str);
        });

        if (isWrongParam) {
            String msg = "잘못된 변수 발생";
            log.error("Error while handling Account constructor:: {}", msg);
            throw new IllegalArgumentException("Error while handling Account Constructor: " + msg);
        }
    }


}

 

2. AccountDto (통신용) 

 

@Data
@Builder
@Slf4j
@AllArgsConstructor
@NoArgsConstructor
public class AccountDto {

    private String username;
    private String password;
    private String email;
    private String age;
    private String role;

}

 

UserDetail(User) >> 인증과 인가를 처리하기 위한 Security 객체. 연동해줘야 합니다.

3. AccountContext

public class AccountContext extends User {

    private final Account account;

    public AccountContext(Account account, Collection<? extends GrantedAuthority> authorities) {

        super(account.getUsername(), account.getPassword(),authorities);
        this.account = account;
    }

    public Account getAccount() {
        return account;
    }
}

 

4. Provider 에서 사용할 UserDetailsService 도 구축해주자 (Account 도 새로 만들었고, Security 연동 객체인 AccountContext 도 수월하게 사용하기 위함) 

 

// @Service("userDetailsService") // 사실 이런 설정 좀 명확히 하고 가야함. 기존 Bean에 대하여, 자동 주입시 어떻게 넣게 되는건지?
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        Account account = userRepository.findByUsername(username);

        if (Objects.isNull(account)) {
            String msg = "유저의 이름을 찾을 수 없습니다";
            log.error("Error while handling loadUserByName in CustomUserDetailsService:: {}", msg);
            throw new UsernameNotFoundException(msg);
        }

        List<GrantedAuthority> roles = new ArrayList<>();
        roles.add(new SimpleGrantedAuthority(account.getRole()));

        // UserDetails를 만들어서 반환할 것이다
        // User 클래스를 통해서 만들 수 있음
        AccountContext ac = new AccountContext(account, roles);

        return ac;
    }
}

// @Service 를 해줬기 때문에, Config에서 따로 할필욘 없긴 함.

// 하지만 Security 자체는 기존 비즈니스 로직에서 제외된 기능 로직에 속한다. 

// 따라서 저거보다는, Config 클래스에 Bean 부분, 인증 인가부분 나눠서 설정해 두면 한번에 확인할 수 있다. 

// 저것도 있지만, 나는 따로 Config 클래스에 Bean 등록 해줄게요!

// 일단 여기까지의 Config는 다음과 같을 것. 

 

* 현재까지 AjaxConfig.class 의 모습

@EnableWebSecurity
public class AjaxSecurityConfig extends WebSecurityConfigurerAdapter {
	
    @Bean
    public UserDetailsService userDetailsService(){
    	return new CustomUserDetailsService();
    }


    // Auth & Autho 관련
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .antMatcher("/api/**")
                .authorizeRequests()
                .anyRequest().authenticated(); // api 하위의 모~든 요청은 인가가 필요함.

        // 지금 로그인 방식 외에는 다 쓰지 않을 거임
        // 다만 formLogin Disable 을 할 경우 Session 처리 관련해서는 직접해줘야 함
        http
                .formLogin()
                .disable();

        http
                .httpBasic()
                .disable();

        http
                .csrf()
                .disable();

    }
}

 

이정도로 해두고, 본격적으로 Security에 사용할 녀석들을 추가해보자. 

 

 

** 참고>> AuthenticationToken 은 Bean 등록을 하지 않는다.

우선 Authentication 객체인 AuthenticationToken 부터 만들어 봅시다. Token 은 내가 직접적으로 선언해서 사용할 것이고, 인증 전, 인증 후 객체가 나뉘게 됩니다. 또한, SecurityContextHolder 에 인증 된 녀석을 직접 넣어서 관리시킬 예정이므로, IOC 컨테이너에서 따로 관리하게 두지 않습니다. 즉, 계속 다양하게 사용해야 하므로 Bean 등록을 하지 않습니다. 

 

 

다음과 같이 Token 을 만들어 봅시다. (어떻게 해야할지 모르겠으니 일단 그냥 상속 받아야 하는 AbstractAuthenticationToken.class 를 복붙하고 오류뜨는거만 해결하기.. ) 

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);
    }

    // 인증 받은 후 권한 부여후 생성자. 유저의 String 권한을 Security Authorization과 연동한다
    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
    }

    public static AjaxAuthenticationToken unauthenticated(Object principal, Object credentials) {
        return new AjaxAuthenticationToken(principal, credentials);
    }


    public static AjaxAuthenticationToken authenticated(Object principal, Object credentials,
                                                                    Collection<? extends GrantedAuthority> authorities) {
        return new AjaxAuthenticationToken(principal, credentials, authorities);
    }

    @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;
    }


}

 

그냥 복붙해서 만들었습니다... 위 객체를 토대로 Ajax 인증을 진행할 것입니다.

 

 

이제 Filter. Filter 는 우선 자신의 필터링을 거쳐야 하는 단계인지, 아니면 바로 후속으로 보내면 되는 것인지 판단을 하게 되는데, Path 가 /api/login 으로 들어오니 attemptAuthentication 을 수행하게 된다. 

 

 

위에서 인증 전 AjaxToken 을 토대로 Manager에게 인증을 요청합니다. 기본적으로 Filter을 만들어보자. 

 

public class AjaxLoginProcessingFilter extends AbstractAuthenticationProcessingFilter {

    private ObjectMapper om = new ObjectMapper();
    private final String AJAX_HEADER = "AJAX_HTTP_REQUEST";

    public AjaxLoginProcessingFilter(){
        super(new AntPathRequestMatcher("/api/login")); // 이 정보와 매칭되면 필터가 작동하게끔 할 수 있음 (Filter의 기능)

    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {

        System.out.println("AjaxLoginFilter 를 지납니다");

        if (!isAjax(request)) {

            throw new IllegalStateException("AJAX Authentication is not supported");
        }

        AccountDto accountDto = om.readValue(request.getReader(), AccountDto.class);

        if (!StringUtils.hasText(accountDto.getUsername()) || !StringUtils.hasText(accountDto.getPassword())) {
            throw new IllegalStateException("AJAX Username or Password is Empty..");
        }

        // 토큰을 가지고 메니저한테 전달하는 행위임.
        // 인증전이므로 이름과 Credentials 를 담아서 보낸다
        AjaxAuthenticationToken ajaxAuthenticationToken = new AjaxAuthenticationToken(accountDto.getUsername(), accountDto.getPassword());

        // 상속 클래스에서 알고 있는 매니저에게 처리하라고 던짐
        return getAuthenticationManager().authenticate(ajaxAuthenticationToken);

    }

    private boolean isAjax(HttpServletRequest request) {

        if (request.getHeader("X-Requested-With").equals(AJAX_HEADER)) {
            return true;
        }else{
            return false;
        }
    }
}

알고 있겠지만 Filter 다음에는 Manager로 전달을 하게 됩니다. 기존 Abstract~Filter.class 를 보시면 알 수 있겟지만, 생성시 AuthenticationManager를 set해주게 되어 있고, AuthenticationManager 처리 없이 Filter를 Bean 등록해버리면 (Abstract~Filter.class 를 업고있기 때문에)

"authenticationManager must be specified"

 에러가 발생하게 됩니다. 

해당 필터에 걸려서 Authentication 객체를 받아서 처리하게 될 manager를 주입해줍시다. 당연히 Config 클래스에서 주입하겠죠? Manager 객체는 따로 없이 그냥 구현 객체를 사용할 것인데, WebSecurityConfigurerAdapter.class 에서 Filter의 Manager 세팅을 지원합니다. 다음과 같이 SecurityConfig.class에 추가 해줍시다. 

 

@EnableWebSecurity
public class AjaxSecurityConfig extends WebSecurityConfigurerAdapter {

    ...

    // 추가
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
   
    @Bean
    public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
        AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter();
        ajaxLoginProcessingFilter.setAuthenticationManager(authenticationManagerBean()); // 매니저를 세팅해줘야 이 쪽으로 Auth객체를 전달을 한다
        return ajaxLoginProcessingFilter;
    }
    
    
    // 기존 ~
    @Bean
    public UserDetailsService userDetailsService(){
    	return new CustomUserDetailsService();
    }
    
    ...
 }

 

그럼 이제 api / login 으로 username, password 정보가 들어오면, 그 정보를 토대로 AccountDto 를 형성하고, 그 정보를 토대로 Authentication 준비를 시작하게 됩니다. 

 

이제 메니저는 자신이 가지고 있는 Provider 들을 참고하여 알맞은 Provider에게 실질적인 인증 처리를 부탁하게 됩니다. AccountDto를 실질적으로 가지고 처리를 해야하므로 AjaxCustomProvider를 또한번 만들어 봅시다. 

 

@RequiredArgsConstructor
public class AjaxAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;


    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        System.out.println("Ajax Auth Provider 가 실행됩니다");

        // UDS 가 필요
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();

        // UDS로 loadUserByName 실행
        AccountContext accountContext = (AccountContext) userDetailsService.loadUserByUsername(username);

        if (!passwordEncoder.matches(password, accountContext.getPassword())) {
            throw new BadCredentialsException("비밀번호 불일치");
        }

        // 일치하면 Authentication 객체가 인증된 객체로 새로 형성후 전달해 주면 됨.
        // MEMO : 왜 Account, null 을 보내주는지? username, pw, Authorities 전달해줘야 하는것 아닌지?
        AjaxAuthenticationToken authenticationToken = new AjaxAuthenticationToken(accountContext.getAccount(), null, accountContext.getAuthorities());

        System.out.println("authenticationToken = " + authenticationToken);

        return authenticationToken;

    }

    // 작동 조건 확인. 사용되는 Authentication 객체가 AjaxAuthToken 이것이면 이 Provider를 사용할 것이다.
    @Override
    public boolean supports(Class<?> authentication) {

        return authentication.equals(AjaxAuthenticationToken.class);
    }
}

 

알다싶이 provider 는 UDs 를 이용하여 동일 Id의 AccountContext 객체를 불러오고, DB에 저장된 비밀번호와 Provider 까지 전달된 비밀번호를 비교하여 일치 여부를 판단합니다. (참고로, 저 supports 함수가 사용하고 있는 토큰을 통해서 적절한 AuthenticationProvider 를 찾는 함수입니다, Manater에서 가지고 있는 Provider를 돌려서 supports 수행 결과가 참인 녀석을 가지고 하게 됩니다) 즉, 실질적인 인증이 이루어지고, 결과적으로 Authentication 객체를 새롭게 생성하여 권한까지 추가후 전달합니다. 

 

 

위에서 UDS는 빈 주입을 해놨고, PasswordEncoder의 주입이 필요하므로 Config Class에 추가해주겠습니다. 또한, 이 Configuration 에서는 해당 Provider를 사용한다고 명시를 해줘야 하므로, Config Class는 다음과 같이 변경됩니다. 

 

@EnableWebSecurity
public class AjaxSecurityConfig extends WebSecurityConfigurerAdapter {
	
    ...

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

    
    @Bean
    public UserDetailsService userDetailsService(){
    	return new CustomUserDetailsService();
    }
    
    @Bean
    public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
        AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter();
        ajaxLoginProcessingFilter.setAuthenticationManager(authenticationManagerBean()); // 매니저를 세팅해줘야 이 쪽으로 Auth객체를 전달을 한다
        return ajaxLoginProcessingFilter;
    }
    
    // 추가
    @Bean
    public PasswordEncoder passwordEncoder(){
    	return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public AuthenticationProvider ajaxAuthenticationProvider() {
        return new AjaxAuthenticationProvider(userDetailsService(), passwordEncoder());
    }

    ...
}

 

Provider 에서 사용할 UserDetailsService 와 PasswordEncoder 각각의 역할체에 대해서 다음과 같이 주입을 완료하였습니다. 이제 Manager, Provider 까지 되었으니, 기본적인 통신 Line 은 구축이 되었습니다. 이제 다시 필터로 올라와서 다음 액션을 취하게 되는데, 기존 Spring에서 지원해주는 formLogin 방식 같은 경우에는 http 세팅에서 다음과 같이 간단히 추가해줄 수 있었습니다. 

 

http
        .formLogin()
        .successHandler(~CUSTOM_HANDLER())
        .failureHandler(~CUSTOM_HANDLER())
        .permitAll()
;

 

하지만 Form Login 은 Disable 되어 있기 때문에, Filter 에게 Filter 처리 이후 Success, Failure에 따라 어떤 처리를 하면 되는지 명시를 해주면 됩니다. 

 

 

(Filter 에서 doFilter 같은 경우 ~~가 아닌경우를 Exception으로 처리하고 그냥 뒤 필터로 진행을 시키기도 하지만, 이 Authentication 필터 같은 경우는 요청을 받아와서 처리를 끝내야 하는 곳입니다. 그러므로 Success, Failure 각각 어떻게 대응할지 Handler 등록을 해줘야 합니다)

 

Success Handler를 만들어봅시다.

 

public class AjaxAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 인증에 성공한 객체가 전달됩니다 Authentication
        // 우리가 Pricipal 에 username 말고 Account 객체를 박아두면 이대로 가지고 나올 수 있음

        Account successAccount = (Account) authentication.getPrincipal();

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

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

 

위에서 Principal과 Credential 을 따로따로 걸지 않고, Principal에 Account 전체 객체를 걸어놨기 때문에, 한번에 가지고 나올 수 있습니다. 다만, 여기서 write를 하지만, DTO로 write하는게 맞긴 하겠죠? 귀찮으니 걍 저렇게 ㄱㄱ

objectMapper를 통해서 객체로 변환, 객체로 등록을 JSON에 맞춰서 자유롭게 할 수 있습니다. 다만, 반드시 getter setter가 되어 있어야 하므로 DTO를 꼭 따로 파야 합니다. Entity 자체에는 Setter 금지이기 때문입니다. 

 

FailureHandler

public class AjaxAuthenticationFailureHandler implements AuthenticationFailureHandler {

    private ObjectMapper om = new ObjectMapper();

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

        String errMsg = "Inavlid Username or Password";

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

        if (exception instanceof BadCredentialsException) {
            errMsg = "Invalid Username or Password";
        } else if (exception instanceof DisabledException) {
            errMsg = "Locked";
        } else if (exception instanceof CredentialsExpiredException) {
            errMsg = "Expired Password";
        }

        om.writeValue(response.getWriter(), errMsg);

    }
}

 

 

이와 같이 핸들러를 만들고, 다음과 같이 Bean 주입후, Filter에 추가를 해줬습니다. 최종적인 Config Class의 모습은 다음과 같다고 볼 수 있습니다. (Authentication 처리된 한정, Session 은 아직 미처리) 

 

@EnableWebSecurity
public class AjaxSecurityConfig extends WebSecurityConfigurerAdapter {

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


    //// BEAN 주입
    @Bean
    public UserDetailsService userDetailsService() {

        return new CustomUserDetailsService();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {

        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

   
    @Bean
    public AuthenticationProvider ajaxAuthenticationProvider() {
        return new AjaxAuthenticationProvider(userDetailsService(), passwordEncoder());
    }

    @Bean
    public AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new AjaxAuthenticationSuccessHandler();
    }

    @Bean
    public AuthenticationFailureHandler authenticationFailureHandler() {
        return new AjaxAuthenticationFailureHandler();
    }


    @Bean
    public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
        AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter();
        ajaxLoginProcessingFilter.setAuthenticationManager(authenticationManagerBean()); // 매니저를 세팅해줘야 이 쪽으로 Auth객체를 전달을 한다
        ajaxLoginProcessingFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler());
        ajaxLoginProcessingFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
        return ajaxLoginProcessingFilter;
    }
    
    // ... HttpSecurity 설정은 맨 위와 변함X
}

 

다음에는 인가처리에 대해서 배워보겠슴둥

 

일단 인가는 Security가 계속 처리해주게 하기 위해서는  Session 을 계속 사용하면 됩니다. 

Custom Filter 를 사용했을 경우에 Authentication을 반환, Security Context 에 강제 주입 등을 해주면 됩니다. 

 

토큰 같은거는 세션에 저장안하려고 사용하는 것이지만, Security 가 Session 을 해주는 것이 편리하기 때문에 저장해서 지속 사용하기도 합니다. 

 

현재 AJAXFIlter 같은 경우는 attemptAuthentication 을 통해서 Manager 로 넘겨지고 수행이 종료된다. AJAX는 이후 인가는 당연히 Security 에 위임할 것이기 때문에 별도의 SHolder를 제어하지는 않을 것. 

 

추가적으로 다음과 같은 사항에서 예외처리를 직접 하고 싶을시 다음과 같이 설정해줄 수도 있다. 

 

// 익명 사용자가 인증이 필요한 자원에 접근함
// 예외 필터가 commence를 호출해서 작업을 처리하고 Client에게 전달

public class AjaxLoginAuthenticationEntryPoint implements AuthenticationEntryPoint {


    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        // 인증 예외가 Param 으로 전달되고 있음
        // 사용자가 인증을 받지 않은 상태로 자원에 접근함
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UnAuthorized User!!");

    }
}

 

// 익명 사용자가 인증이 필요한 자원에 접근함
// 예외 필터가 commence를 호출해서 작업을 처리하고 Client에게 전달

public class AjaxLoginAuthenticationEntryPoint implements AuthenticationEntryPoint {


    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        // 인증 예외가 Param 으로 전달되고 있음
        // 사용자가 인증을 받지 않은 상태로 자원에 접근함
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UnAuthorized User!!");

    }
}

 

그리고 설정에는 다음과 같이 추가해주면 됩니다. 

 

@Bean
public AjaxAccessDeniedHandler ajaxAccessDeniedHandler() {
    return new AjaxAccessDeniedHandler();
}


// Auth & Autho 관련
@Override
protected void configure(HttpSecurity http) throws Exception {

    ...
    
    http
            .exceptionHandling()
            .authenticationEntryPoint(new AjaxLoginAuthenticationEntryPoint()) // 1번 사항
            .accessDeniedHandler(ajaxAccessDeniedHandler()); //2번 사항
            
    ...
    
}

 

 

728x90