240304 TIL #337 Custom Security Filter

김춘복·2024년 3월 4일
0

TIL : Today I Learned

목록 보기
337/571

Today I Learned

Spring Security 이어서 공부


Custom Security Filter 적용

CustomSecurityFilter

@RequiredArgsConstructor를 클래스 위에 달고 OncePerRequestFilter를 상속.
userDetailsService와 passwordEncoder를 private final로 가져오고
doFilterInternal 메서드를 @Override

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

    String username = request.getParameter("username");
    String password = request.getParameter("password");

    System.out.println("username = " + username);
    System.out.println("password = " + password);
    System.out.println("request.getRequestURI() = " + request.getRequestURI());

//        뒤의 두 URI 중 하나로 들어오면서 동시에 유저명과 비밀번호가 null이 아니면
    if(username != null && password  != null && (request.getRequestURI().equals("/api/user/login") || request.getRequestURI().equals("/api/test-secured"))){
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        // 비밀번호 확인
        if(!passwordEncoder.matches(password, userDetails.getPassword())) {
            throw new IllegalAccessError("비밀번호가 일치하지 않습니다.");
        }

        // 인증 객체 생성 및 등록
        // 빈 context만들어 인증객체(authentication)를 넣고, 그 context를 context holder에 넣는다.
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        // 위에서 비밀번호 검증을 했기때문에 credential에 비밀번호 넣을 필요 x
        Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        context.setAuthentication(authentication);

        SecurityContextHolder.setContext(context);
    }

    filterChain.doFilter(request,response);
}
  • doFilterInternal()은 request, response, filterChain을 파라미터로 받는다.
    api Request가 오면 HTTP객체가 Filter를 거쳐 Controller까지 가는데 여기가 그 필터.
    Filter는 Chain으로 연결되어 있는데 FilterChain 객체를 통해 Filter끼리 이동.
    filterchain 객체를 받아 처리한 뒤 filterChain.doFilter(request,response)로 다음 필터로 전달.
    (참고로 반환 값은 void라서 없다)
    필터를 거치다가 예외처리가 되면 다음 필터로 못가고 이전 필터로 예외가 넘어간다.

  • request.getParameter("키 이름") 으로 클라이언트쪽에서 넘어오는 파라미터 가져온다.

  • request.getRequestURI()는 들어온 URI를 확인하는 메서드.


  • WebSecurityConfig에서 securityFilterChain에 Custom Filter 등록해야 한다.
    (private final UserDetailsServiceImpl userDetailsService; 필드에 추가)
    UsernamePasswordAuthenticationFilter 이전에 Custom 필터 적용.
// Custom Filter 등록하기
http.addFilterBefore(new CustomSecurityFilter(userDetailsService, passwordEncoder()), UsernamePasswordAuthenticationFilter.class);
  • UserService에서 회원가입 메서드를 실행시킬 때 password는
    WebSecurityConfig에서 만들었던 passwordEncoder() 메서드를 활용해서 아래와 같이
    passwordEncoder.encode(signupRequestDto.getPassword())로 암호화해서 Repository에 저장.

@AuthenticationPrincipal

@PostMapping("/login") // Controller에서 파라미터로 UserDetails 받을 때 사용
public String login(@AuthenticationPrincipal UserDetails userDetails) {

Authentication(인증객체)의 principal 부분의 값(userDetails)을 가져온다.
Controller에서 파라미터로 UserDetails 받을 때 앞에 사용.
(userDetails의 게터로 User의 entity와 username, password 가져올 수 있다.)


UserRoleEnum

유저 권한 Enum으로 등록. "ROLE_USER"와 "ROLE_ADMIN"으로 등록.
역할(Role)에 권한(Authority) 속성 부여 / .getAuthority()로 권한을 가져올 수 있다.

public enum UserRoleEnum {
    USER(Authority.USER),  // 사용자 권한
    ADMIN(Authority.ADMIN);  // 관리자 권한

    private final String authority;

    UserRoleEnum(String authority) {
        this.authority = authority;
    }

    public String getAuthority() {
        return this.authority;
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

@Secured

Admin 페이지가 있어서 일반 사용자는 관리자 페이지에 접근하지 않게 하려한다.
이럴 때 @Secured를 통해 API별로 권한 제어를 할 수 있다. (다른 방법도 더 있긴 하다.)

  • Spring Security에 권한(Authority) 설정 방법

    회원 상세정보 (UserServiceImpl)를 통해 권한(Authority)을 1개 이상 설정 가능
    "권한이름" 규칙을 정해야 한다. ex) "ROLE_"로 시작해서 "ROLE_USER", "ROLE_ADMIN"

  • API 별 권한 제어 방법
    WebSecurityConfig 위에 @EnableGlobalMethodSecurity(securedEnabled = true)를 달아주고
    Controller안의 API에 @Secured("권한 이름")을 선언하면 해당 권한만 사용 가능.

// (관리자용) 등록된 모든 상품 목록 조회
    @Secured("ROLE_ADMIN")
    @GetMapping("/api/admin/products")
    public List<Product> getAllProducts() {
        return productService.getAllProducts();
    }

접근제한 페이지 구현

접근 권한이 없을때 403(Forbidden) 에러가 발생.
1. forbidden.html 파일 만들어 templates 폴더에 넣고
2. Controller에 /forbidden 페이지 post로 구현한 다음

    @PostMapping("/forbidden")
    public ModelAndView forbidden() {
        return new ModelAndView("forbidden");
    }
  1. WebSecurityConfig 위에 @EnableGlobalMethodSecurity(securedEnabled = true)추가한 뒤 그 안의 securityFilterChain() 메서드의 아래쪽에 아래 코드 추가.
// 접근 제한 페이지 이동 설정 
http.exceptionHandling().accessDeniedPage("/api/user/forbidden");
  1. 원하는 Controller의 API 위에 @Secured("권한") 달면 된다.
    @Secured(value = UserRoleEnum.Authority.ADMIN)

401, 403 Error ExceptionHandling

401 Error는 Authorization 즉, 인증과정에서 실패할 때 발생하는 에러이다.
WebSecurityConfig의 securityFilterChain()에 아래의 코드를 넣고,

// 401 Error 처리, Authorization 즉, 인증과정에서 실패할 시 처리
http.exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint);
// 403 Error 처리, 인증과는 별개로 추가적인 권한이 충족되지 않는 경우
http.exceptionHandling().accessDeniedHandler(customAccessDeniedHandler);

SecurityExceptionDto를 int statusCode와 String msg 변수로 만들고,
AccessDeniedHandler를 implements한 CustomAccessDeniedHandler를 만든다.(403처리)

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
	// DTO에다가 Forbidden에러코드와 Forbidden에러 메세지를 넣어 만든다.
    private static final SecurityExceptionDto exceptionDto =
            new SecurityExceptionDto(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException{

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

        try (OutputStream os = response.getOutputStream()) {
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.writeValue(os, exceptionDto);
            os.flush();
        }
    }
}

AuthenticationEntryPoint를 implements한 CustomAuthenticationEntryPoint도 만든다(401처리)

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private static final SecurityExceptionDto exceptionDto =
            new SecurityExceptionDto(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());

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

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

        try (OutputStream os = response.getOutputStream()) {
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.writeValue(os, exceptionDto);
            os.flush();
        }
    }
}

둘 다 secutiry 패키지에 생성.

profile
Backend Dev / Data Engineer

0개의 댓글