개발/Spring Security(Hodol)

Spring - Spring Security (예외 핸들러 처리, 커스텀 인증 생성)

잇(IT) 2023. 10. 12. 13:15
728x90
- Handler

- 핸들러(Handler)를 생성하는 이유는 웹 애플리케이션에 특정 이벤트 또는 요청에 대한 사용자 정의 로직을 실행하고 처리하기 위함이다.

 

- SecurityConfig.java

@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        return http
                .authorizeHttpRequests()
.....................
                    .defaultSuccessUrl("/")
                //성공한 뒤 이동하는 페이지
                    .failureHandler(new LoginFailHandler(objectMapper))
                //로그인 실패시 발생하는 핸들러
                .and()
                .exceptionHandling(e -> {
                    e.accessDeniedHandler(new Http403Handler(objectMapper));
                    e.authenticationEntryPoint(new Http401Handler(objectMapper));
                            //로그인이 필요한 페이지인데 로그인이 안된 상태에서 접근 했을 때
                })
* exceptionHandling : Spring Security에서 보안 예외 처리에 대한 구성을 지정하는 데 사용된다.

- accessDenieHandler : 접근 권한이 없는 경우 발생하는 예외에 대한 처리를 한다.

- authenticationEntryPoint : 인증되지 않은 사용자가 보호된 자원에 액세스하려고 할 때 발생하는 예외에 대한 처리를 한다.

* failureHandler : 로그인 실패시 발생하는 핸들러다.

1. securityFilterChain의 builder 구성 중 .failureHandler, e.accessDeniedHandler, e.authenticationEntryPoint와 같이 로그인 실패, 예외가 발생할 때를 위한 메서드가 있다. 각 메서드의 파라미터는 핸들러 객체를 가지고 있는데, 각 핸들러는 해당 메서드가 호출될 때 특정 로직을 실행시키기 위해 있고, 현재 각 핸들러는 예외에 대한 처리를하기 위한 객체이다.


- LoginFailHandler.java

@Slf4j
@RequiredArgsConstructor
public class LoginFailHandler implements AuthenticationFailureHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
      log.error("[인증오류] 아이디 혹은 비밀번호가 올바르지 않습니다.");

        ErrorResponse errorResponse = ErrorResponse.builder()
                .code("400")
                .message("아이디 혹은 비밀번호가 올바르지 않습니다.")
                .build();

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        //응답 한글이 꺠지는 것을 해결하기 위함 -> 좀 알아봐야 할듯

        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        //스프링도 오류와 관련된 enum이 있다.

//        String json = objectMapper.writeValueAsString(errorResponse);
//        response.getWriter().write(json);

        objectMapper.writeValue(response.getWriter(), errorResponse);
        //위에 2개를 합친 코드가 위와 같다.
    }
}

1. LoginFailHandler는 로그인 실패시 실행되는 클래스다.

2. Spring Security가 제공하는 AuthenticationFailureHandler 인터페이스를 상속받아 사용자 인증에 실패한 경우 실행 시킬 메서드를 구현한다.

3. onAuthenticationFailure() 메서드는 HttpServletRequest, HttpServletResponse, AuthenticationException를 파라미터로 받는다.

4. 기본에 커스텀으로 생성한 ErrorResponse 객체를 이용하여 해당 핸들러가 발생했을 때 클라이언트에게 전달할 응답을 구성하여 전달한다.

5. response.setContentType(MediaType.APPLICATION_JSON_VALUE);를 통해 응답 Content-Type를 설정한다.

//        String json = objectMapper.writeValueAsString(errorResponse);
//        response.getWriter().write(json);

6. getWriter()는 response(응답 객체)의 문자열 데이터를 출력하기 위한 스트림이다. .write를 통해 json 형태의 문자열을 HTTP response로 출력 할 수 있다.

7. 위 두 줄을 한 줄로 줄이면 아래 코드와 같이 작성할 수 있다.

objectMapper.writeValue(response.getWriter(), errorResponse);

- Http401Handler.java

@Slf4j
@RequiredArgsConstructor
public class Http401Handler implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        log.error("[인증오류] 로그인이 필요합니다.");

        //HttpServletRequest,Response를 가지고 응답값을 활용해서 전달하면 해결이 될 것이다.

        ErrorResponse errorResponse = ErrorResponse.builder()
                .code("401")
                .message("로그인이 필요합니다.")
                .build();

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        //응답 한글이 꺠지는 것을 해결하기 위함 -> 좀 알아봐야 할듯

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        //스프링도 오류와 관련된 enum이 있다.

//        String json = objectMapper.writeValueAsString(errorResponse);
//        response.getWriter().write(json);

        objectMapper.writeValue(response.getWriter(), errorResponse);
        //위에 2개를 합친 코드가 위와 같다.
    }
}

1. Http401Handler도 LoginFailHandler와 마찬가지로 예외 발생에 대한 handler인데, 구현체의 차이가 있다. AuthenticationEntryPoint 인터페이스를 구현하게 되고, 구현 메서드인 commence도 마찬가지로 request, response, authException 3개를 파라미터로 전달 받고, response에 builder 패턴을 통해 생성한 ErrorResponse 객체를 응답에 포함하여 클라이언트에게 전달한다.

2. setContentType, setCharacterEncoding, setStatus를 통해 Header 속성을 설정하고, objectMapper를 통해 body에 JSON 형태로 값을 넣어 클라이언트에게 전달한다.


- Http403Handler.java

@Slf4j
@RequiredArgsConstructor
public class Http403Handler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;


    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.error("[인증오류] 403");

        ErrorResponse errorResponse = ErrorResponse.builder()
                .code("403")
                .message("접근할 수 없습니다..")
                .build();

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        //응답 한글이 꺠지는 것을 해결하기 위함 -> 좀 알아봐야 할듯

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        //스프링도 오류와 관련된 enum이 있다.

//        String json = objectMapper.writeValueAsString(errorResponse);
//        response.getWriter().write(json);

        objectMapper.writeValue(response.getWriter(), errorResponse);
        //위에 2개를 합친 코드가 위와 같다.
    }
}

1. Http403Handler도 똑같은 형식으로 코드가 작성되었고, 해당 클래스의 경우 AccessDeniedHandler를 구현하고 있다는 차이점이 있다.


- Handler TEST

- auth.http

### 회원가입

POST http://localhost:8080/auth/signup
Content-Type: application/json

{
  "name": "백인수",
  "email": "saymay10@naver.com",
  "password": "1234"

}

### 로그인

POST http://localhost:8080/auth/login
Content-Type: application/x-www-form-urlencoded

username=saymay10@naver.com&password=12345&remember=1

### 메인 페이지

GET http://localhost:8080

### 사용자 페이지

GET http://localhost:8080/user

### 관리자 페이지

GET http://localhost:8080/admin

 

1. 회원가입 후 비밀번호 틀렸을 때

 

2. 회원가입 및 로그인 성공 후 허용되지 않은 역할이 접근할 때

 

3. 그 외 정상적으로 역할과 권한이 주어진 페이지에 접근할 때


- 커스텀 인증 생성

- 기존의 로그인 인증은 form으로 전달받아 SecurityFilterChain의 formLogin을 통해 인증 받았다.

- formLogin은 form으로 전달받을 수 밖에 없기 때문에 JSON 형태의 String으로 인증을 하기 위해 인증을 커스텀 할 수 있다.

 

- formLogin()

1. formLogin() 메서드는 FormLoginConfigurer<HttpSecurity> 클래스를 반환타입으로 가진다.

2. getOrApply() 메서드의 파라미터인 FormLoginConfigurer<>()의 생성자를 확인해보면

부모 클래스의 생성자를 통해 new UsernamePasswordAuthenticationFilter() 객체를 생성한다.

3. UsernamePasswordAuthenticationFilter() 객체가 생성되면, 위의 attemptAuthentication 메서드가 실행되고, 해당 메서드는 인증을 시도할 때 내부에서 일어나는 로직을 작성한 것이다. (Spring Security에서 제공하는 것)

4. 위 메서드는 return this.getAuthenticationManager().authenticate(authRequest);를 반환하는데, 이는 UsernamePasswordAuthenticationFilter의 부모 클래스인 AbstractAuthenticationProcessingFilter의 메서드 중 하나이다.

5. AuthenticationManager는 여러 AuthenticationProvider가 등록되어 있고, 사용자 정의 인증 로직에 따라 Provider를 지정하여 인증 로직을 설정한 다음 Manager에게 전달 할 수 있다.

 

- 인증 흐름
1. 기존 form 방식은 formLogin이 호출되면 FormLoginConfigurer<> 인스턴스가 생성되고
2. FormLoginConfigurer 클래스의 생성자에서 UsernamePasswordAuthenticationFilter와 관련된 설정이 초기화 된다.
3. attemptAuthentication 메서드는 사용자가 로그인을 시도할 때마다 호출되며, 인증 시도 로직을 처리한다.
4. attemptAuthentication 메서드가 성공하면 사용자가 로그인되고, 실패하면 예외가 발생한다.
5. attemptAuthentication 메서드는 해당 메서드에서 반환하는 authenticationManager를 통해 인증을 수행하고
6. authenticationManager는 여러 authenticationProvider를 포함하고 있고, 상황에 따라 Provider 중 하나를 선택에 사용자 정의 로직을 작성 할 수 있다.

- 사용자 인증 커스텀

- UsernamePasswordAuthenticationFilter와 유사한 클래스를 새롭게 생성하여 인증 로직을 커스텀 할 것이다.

- UsernamePasswordAuthenticationFilter는 form으로부터 데이터를 받아 인증을 처리했지만, 새롭게 커스텀하는 사용자 인증 방식은 JSON 데이터를 받아 사용자 인증을 할 것이다.

 

- EmailPasswordAuthFilter.java

public class EmailPasswordAuthFilter extends AbstractAuthenticationProcessingFilter {

    private final ObjectMapper objectMapper;

    public EmailPasswordAuthFilter(String loginUrl, ObjectMapper objectMapper) {
        super(loginUrl);
        this.objectMapper = objectMapper;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        EmailPassword emailPassword = objectMapper.readValue(request.getInputStream(), EmailPassword.class);
        //토큰을 생성해줘야 한다. -> 굳이 커스터마이징 하진 않을 것이다.
        UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(
                emailPassword.email,
                emailPassword.password
        );
        token.setDetails(this.authenticationDetailsSource.buildDetails(request));
        return this.getAuthenticationManager().authenticate(token);
    }

    @Getter
    private static class EmailPassword{
        private String email;
        private String password;
    }
}

1. 객체를 JSON으로 변환하기 위한 ObjectMapper를 추가해주고, 생성자 파라미터로 loginUrl, objectMapper를 받는다. 여기서 파리미터로 받는 loginUrl은 로그인 데이터를 처리하기 위한 Url 경로를 처리하는 경로다.

2. AbstractAuthenticationProcessingFilter 클래스의 추상 메서드인 attemptAuthentication을 구현 한다.

3. UsernamePasswordAuthenticationToken은 Spring Security에서 사용자 이름과 비밀번호를 사용하여 인증을 시도할 때 사용되는 Authentication 객체의 구현이다.

4. JSON으로 전달 받은 데이터를 EmailPassword 클래스에 매핑하고, UsernamePasswordAuthenticationToken에 로그인 ID, PASSWORD를 넣어준다. (unauthenticated() 메서드의 파라미터는 username, password를 받는다.)

5. return this.getAuthenticationManager().authenticate(toke)에 의해 사용자가 인증되고, AuthenticationManager의 authenticate 메서드를 통해 인증을 시도한다.


- SecurityConfig.java

.....................
//                .formLogin()
//                    .loginPage("/auth/login")
//                    //로그인 페이지 주소
//                    .loginProcessingUrl("/auth/login")
//                    //실제 post로 값을 받아서 검증을 하는 주소
//                    .usernameParameter("username")
//                    .passwordParameter("password")
//                    .defaultSuccessUrl("/")
//                //성공한 뒤 이동하는 페이지
//                    .failureHandler(new LoginFailHandler(objectMapper))
//                //로그인 실패시 발생하는 핸들러
//                .and()

                .addFilterBefore(emailPasswordAuthFilter(), UsernamePasswordAuthenticationFilter.class)
......................

	@Bean
    public EmailPasswordAuthFilter emailPasswordAuthFilter() {
        EmailPasswordAuthFilter filter = new EmailPasswordAuthFilter("/auth/login", objectMapper);
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(new SimpleUrlAuthenticationSuccessHandler("/"));
        filter.setAuthenticationFailureHandler(new LoginFailHandler(objectMapper));
        filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
        //실제로 인증이 완료되었을 때 요청 내에서 인증이 유효하도록 이게 있어야 세션이 발급된다.

        SpringSessionRememberMeServices rememberMeServices = new SpringSessionRememberMeServices();
        rememberMeServices.setAlwaysRemember(true);
        rememberMeServices.setValiditySeconds(3600 * 24 * 30);
        filter.setRememberMeServices(rememberMeServices);
        return filter;
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService(userRepository));
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
        //ProviderManager 얘를 기본적으로 사용한다.
    }

1. 기존에 사용하던 formLogin()를 통해 로그인을 인증하는 것이 아닌 새롭게 정의한 EmailPasswordAuthFilter를 통해 로그인을 인증할 것이다.

.addFilterBefore(emailPasswordAuthFilter(), UsernamePasswordAuthenticationFilter.class)

2. Spring Security는 경로 요청 및 사용자 인증에 있어 여러 filter를 거치게 되는데, UsernamePasswordAuthenticationFilter를 거치기 이전에 emailPasswordAuthFilter를 거치도록 설정한다.

3. emailPasswordAuthFilter() 메서드는 EmailPasswordAuthFilter 객체를 생성하고, AuthenticationManager, AuthenticationSuccessHandler, AuthenticationFailureHandler... 등 여러 메서드를 구현한다.

4.

 AuthenticationManager : 사용자의 인증을 처리하는 주체
 AuthenticationSuccessHandler : 인증 성공 시 실행 할 Handler
 AuthenticationFailureHandler : 인증 실패 시 실행 할 Handler

등... 여러 인스턴스를 설정 할 수 있다.

@Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService(userRepository));
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
        //ProviderManager 얘를 기본적으로 사용한다.
    }

5. AuthenticationManager는 인증을 처리하는 주체이고(일반적으로 사용자 이름과 비밀번호를 기반으로 자격을 증명한다.), AuthenticationManager를 구현하는 Provider는 여러가지가 있고, 그 중에 DaoAuthenticationProvider를 사용한다.

6. DaoAuthenticationProvider : 사용자 인증을 데이터베이스와 같은 데이터 저장소를 통해 확인하는 데 사용된다.

7. UserDetailService : 사용자 정보를 데이터베이스 또는 다른 저장소에서 검색하는 데 사용되는 서비스이다.

8. PasswordEncoder : 사용자 비밀번호를 안전하게 저장하고 검증하기 위한 암호화 방법을 설정한다.


- auth.http

### 회원가입

POST http://localhost:8080/auth/signup
Content-Type: application/json

{
  "name": "백인수",
  "email": "saymay10@naver.com",
  "password": "1234"

}

### 로그인

POST http://localhost:8080/auth/login
Content-Type: application/json

{
  "email": "saymay10@naver.com",
  "password": "1234"
}

### 메인 페이지

GET http://localhost:8080

### 사용자 페이지

GET http://localhost:8080/user

### 관리자 페이지

GET http://localhost:8080/admin

1. 기존의 form 형식으로 전달하는 방식에서 JSON 형태로 데이터를 전달하는 Test로 변경되었다.

2. /auth/login body에 JSON 형태로 다음과 같이 값이 전달되면, EmailPassowrdAuthFilter의 attemptAuthentication 메서드를 통해 email과 password가 AuthenticationManager에 전달되어 인증을 받게 된다.

3. AuthenticationManager는 현재 DaoAuthenticationProvider로 구현되어 있고, 해당 Provider를 통해 DB에 저장된 사용자와 비교하여 인증을 처리하게 된다.

4. 인증이 성공, 실패하게 되면 SuccessHandler 혹은 FailureHandler가 실행되게 되고, 그 뒤에 추가로 설정한 설정들이 설정되어 인증 후 여러가지들이 설정되게 된다...

728x90