꿈틀꿈틀 개발일기

Spring | 인증과 인가 및 예외 처리

by jeongminy

1. 인증 구현하기

swagger에서 토큰 인증을 테스트 하기 위해 SwaggerConfig에 인증 설정을 추가해준다.

package com.teamsparta.courseregistration.infra.swagger

import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class SwaggerConfig {

    @Bean
    fun openAPI(): OpenAPI {
        return OpenAPI()
            .addSecurityItem(
                SecurityRequirement().addList("Bearer Authentication")
            )
            .components(
                Components().addSecuritySchemes(
                    "Bearer Authentication",
                    SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("Bearer")
                        .bearerFormat("JWT")
                        .`in`(SecurityScheme.In.HEADER)
                        .name("Authorization")
                )
            )
            .info(
                Info()
                    .title("Course API")
                    .description("Course API schema")
                    .version("1.0.0")
            )
    }
}

 

GlobalExceptionHandler에 IllegalArgumentException를 추가한다.

IllegalArgumentException예외가 없을 경우 500이 뜨는데,

이 예외를 추가 해 줄 경우, role이 적절 하지 않을 때에 400과 "Invalid role"을 반환해준다.

@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(IllegalArgumentException::class)
    fun handleIllegalArgumentException(e: IllegalArgumentException): ResponseEntity<ErrorResponse>{
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse(e.message))
    }
}

 

SecurityConfig를 수정해준다.

이제 로그인을 하지 않은 사람은 접근 할 수 없도록 했을 때, 인증 실패 시 예외처리를 해주어야 한다.

@Configuration
@EnableWebSecurity //http관련 보안 기능을 설정 하기 위해
@EnableMethodSecurity
class SecurityConfig(
    private val jwtAuthenticationFilter: JwtAuthenticationFilter, //토큰 인증을 하지 않는 사람은 filter를 통과 할 수 없도록 하기 위해 주입!
    private val authenticationEntryPoint: AuthenticationEntryPoint, //JWT 인증 실패시 발생할 수 있는 예외 처리를 위해 주입
    private val accessDeniedHandler: AccessDeniedHandler //인가 실패시 예외처리를 해주기 위해 주입
) {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        //filterChain 함수는 Spring Security에서 사용되는 SecurityFilterChain 객체를 생성하는 함수입니다.
        //이 함수는 http라는 HttpSecurity 객체를 매개변수로 받아와서 SecurityFilterChain을 구성하고 반환합니다.
        //즉, HTTP 요청에 대한 인가 규칙을 설정하는 부분입니다. 즉, 어떤 요청이 허용되는지, 인증이 필요한지 등을 지정할 수 있습니다.
        return http
            .httpBasic { it.disable() } // BasicAuthenticationFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter 제외
            .formLogin { it.disable() } // UsernamePassworedAuthenticationFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter 제외
            .csrf { it.disable() } // CsrfFilter 제외
            .authorizeHttpRequests {
                it.requestMatchers(
                    "/login", //로그인 아무나 할거야
                    "/signup", //회원가입 아무나 할거야
                    "/swagger-ui/**", //Swagger 문을 열어줘
                    "/v3/api-docs/**" //Swagger 문을 열어줘
                ).permitAll() //해당 경로에 대해서는 모든 사용자에게 접근을 허용합니다. (인증하지 않아도 접근 가능)
                    .anyRequest().authenticated() //anyRequest() 메서드는 나머지 모든 요청에 대해서 권한이 필요하다는 것을 의미
            }
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
            //addFilterBefore 메서드를 사용하여 jwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 이전에 수행하도록 함.
            // jwtAuthenticationFilter가 UsernamePasswordAuthenticationFilter 이전에 실행해야 하는 이유는? 인증 방식의 우선순위, 효율성, 보안 측면에서 유리함.

            .exceptionHandling {
                it.authenticationEntryPoint(authenticationEntryPoint)
                //authenticationEntryPoint(authenticationEntryPoint)은
                //인증되지 않은 요청이 접근하려고 할 때 호출되는 인증 진입 지점을 설정합니다.
                // 인증되지 않은 사용자가 접근할 경우, 해당 지점에서 인증에 필요한 동작을 수행하게 됩니다.

                it.accessDeniedHandler(accessDeniedHandler)
                //accessDeniedHandler(accessDeniedHandler)는
                //인증된 사용자가 요청에 대한 접근 권한이 없을 때 호출되는 접근 거부 처리자를 설정합니다.
                //인증된 사용자가 요청에 대한 권한이 없는 경우, 해당 처리자를 통해 접근 거부에 대한 동작을 수행하게 됩니다.
            }
            .build()
    }
}

 

2. 인증 예외 처리

CustomAuthenticationEntryPoint 를 생성한다.

@Component
//이 코드는 JWT 검증에 실패한 경우에 호출되는 인증 진입점으로,
//클라이언트에게 401 Unauthorized 상태 코드와 함께 JSON 형식의 에러 응답을 반환합니다.
//이를 통해 클라이언트는 JWT의 검증 실패를 알 수 있습니다.
class CustomAuthenticationEntryPoint: AuthenticationEntryPoint {
    override fun commence( //인증에 실패한 경우 호출되는 메서드입니다.
        request: HttpServletRequest,
        response: HttpServletResponse,
        authException: AuthenticationException
    ) {

        //response객체를 사용하여 응답에 대한 설정을 합니다.
        response.status = HttpServletResponse.SC_UNAUTHORIZED //401 Unauthorized 상태 코드를 반환
        response.contentType = MediaType.APPLICATION_JSON_VALUE //응답의 컨텐츠 타입을 JSON으로 지정합니다.
        response.characterEncoding = "UTF-8" //문자 인코딩을 UTF-8로 지정합니다.

        //에러 응답 생성
        val objectMapper = ObjectMapper() //ObjectMapper를 사용하여 ErrorResponse 객체를 JSON 형식의 문자열로 변환합니다.
        val jsonString = objectMapper.writeValueAsString(ErrorResponse("JWT verification failed")) //ErrorResponse는 "JWT verification failed"라는 메시지를 담고 있습니다.
        response.writer.write(jsonString) //이 문자열을 response.writer를 사용하여 응답에 작성합니다
    }

}

 

 

3. 인가 란?

인증이 된 상태에서 특정 자원(Resource)에 대한 권한(Permission)을 체킹을 하는 과정이고, 최종으로 특정 Command에 대한 접근을 제어(Acess Control) 합니다.

도메인의 요구 사항에 따라 권한을 구현하는 방법에는 RBAC와 ABAC 두가지가 있다.

 

RBAC(Role-Based Access Control) 

- Level 0 (우리가 선택한 방법)
    - 모든 User는 한 가지 역할을 가질 수 만 있습니다. 역할에 따라 권한이 결정됩니다.
- Level 1 (Standard RBAC)
    - 모든 User는 권한을 정의하는 역할을 한 가지 이상으로 가질 수 있습니다. 두 가지 이상의 역할을 가진 User도 존재합니다. 즉, Student이면서 Tutor일 수도 있는거죠!
- Level 2 (Hierarchical RBAC)
    - 역할간의 계층 관계가 존재합니다. 상위 계층의 역할은 하위 계층의 역할을 모두 수행할 수 있습니다. Administrator - Manager - User 의 관계라고 볼 수 있어요!
- Level 3 (Constrained RBAC)
    - 역할별로 가능한는 모든 권한 목록은 정의가 되어있되, 권한에 제약이 걸릴 수 있는 형태의 RBAC입니다. 각 권한을 수행하는데 제약이 있는 RBAC 형태입니다. 예를들어, 모두 동일한 팀원이지만, 누구는 마케팅을 담당하고, 누구는 개발을 담당할 수 있어요! 이때 보안을 위해 각자가 가능한 권한을 분리할 수 있겠죠?
- Level 4 (Dynamic RBAC)
    - 역할별로 가능한 권한 목록이 동적으로 변경될 수 있는 형태입니다.

 

ABAC(Attribute-Based Access Control)

속성 별로 접근을 제어하는 개념입니다. 예시로, Google Drive가 있다.

- 주체(Subect)의 속성
    - 엑세스를 요구하는 주체 또는 사용자의 속성입니다. 역할이 될 수도 있고, 그룹이 될 수도 있고, 사용자의 식별자 등이 될 수 있습니다.
- 자원(Resource)의 속성
    - 해당 자원을 생성한 주체, 자원의 생성일, 자원의 포멧 등이 될 수 있겠죠.
- 행동(Action)
    - 주체가 자원에 수행하는 동작이에요. 읽기, 쓰기, 실행 등이 될 수 있습니다.

 

 

 

RBAC 를 구현하는 방법

인가를 구현하는 방법에도 대표적으로 3가지 방법이 있습니다.

- 요청 URI 별로 권한을 분리 (e.g. `/admin/**` 에 대해서는 ADMIN 역할을 가진 사람만 수행 가능)

Controller에서 요청을 수행하기 전에 역할 혹은 권한을 확인. (우리가 선택한 방법)
    - Level 0, Level1 이라면 역할만을 확인하면 되고, 더 복잡해진다면 권한도 확인해야겠죠?
Controller에서 요청을 수행한 이후에 역할 및 권한을 확인.
    - Controller에서 요청을 수행하면 보통 특정 리소스를 획득하게 되고, 이때 해당 리소스에 대해 권한이 있는지 추가적으로 확인할 수 있어요.

 

Spring Security 에서는 AuthorizationManager 가 위에서 설명한 것들을 담당합니다.

AuthorizationManger 의 구현체로,

RequestMatcherDelegatingAuthorizationManager,

PreAuthorizeAuthorizationManager (Controller 타기 전에),

PostAuthorizeAuthorizationManager (Controller 타고 난 후에),

가 대표적인 역할을 수행합니다.

 

그런데!! 우리는 이런 구현체들을 직접 알 필요 없이 Spring Security에서 제공하는

MethodSecurity 를 사용해서, 편리하게 적용할 수 있음.

-> 먼저, MethodSecurity를 활성화 하기 위해 SecurityCongfig에 @EnableMethodSecurity 어노테이션을 붙여준다.

 

이후, 적용하는 방법으로

Controller를 타기 전에 권한을 확인하는 방법으로는 @Secured@PreAuthorize를 붙여 줄 수 있고,

Controller를 타고 난 후에 권한을 확인하는 방법으로 @PostAuthorize 가 있다.

사용 예시는 아래처럼 쓸 수 있다.

@ Secured 보다 @PreAuthorize 를 사용하기를 추천!!

 

@PreAuthorize

Controller의 메서드에 적용하여 메서드를 호출하기 전에 사용자의 권한을 확인하는 기능을 제공합니다. 이 어노테이션은 Spring AOP와 함께 동작하여 메서드 호출 시 보안 검사를 수행합니다.
SpEL(Spring Expression Language)을 사용하여 복잡한 권한 표현식을 작성할 수 있습니다. 이를 통해 SecurityExpressionRoot에 있는 다양한 메서드와 속성에 접근할 수 있으며, 메서드의 매개변수에도 접근할 수 있습니다.

따라서 일반적으로 @Secured 어노테이션보다는 @PreAuthorize 어노테이션을 사용하는 것을 추천합니다.

 

 

SpEL

SpEL(Spring Expression Language)은 스프링 프레임워크에서 제공하는 표현 언어입니다. 

SpEL은 문자열 형태로 작성되며, 런타임 시에 평가되어 다양한 컨텍스트에서 사용될 수 있습니다.

 

SpEL은 다음과 같은 기능을 제공합니다:

  • 프로퍼티 접근: SpEL을 사용하여 객체의 프로퍼티에 접근할 수 있습니다. 예를 들어, user.name과 같은 형식으로 객체 user의 name 프로퍼티에 접근할 수 있습니다.
  • 메서드 호출: SpEL을 사용하여 객체의 메서드를 호출할 수 있습니다. 예를 들어, user.getName()과 같은 형식으로 객체 user의 getName() 메서드를 호출할 수 있습니다.
    연산자: SpEL은 다양한 연산자를 지원합니다. 산술 연산자(+, -, *, /), 논리 연산자(&&, ||), 비교 연산자(==, !=, >, <) 등을 사용할 수 있습니다.
  • 조건문과 반복문: SpEL은 조건문(if-else)과 반복문(for, while)과 같은 제어 구문을 제공합니다. 이를 통해 SpEL을 더욱 강력하게 활용할 수 있습니다.
  • 컬렉션 접근: SpEL을 사용하여 컬렉션 내의 요소에 접근할 수 있습니다. 예를 들어, users[0]과 같은 형식으로 컬렉션 users의 첫 번째 요소에 접근할 수 있습니다.
  • SpEL은 주로 Spring Framework의 다양한 기능과 통합하여 사용됩니다. 예를 들어, @Value 어노테이션을 사용하여 프로퍼티 값을 주입받을 때 SpEL을 사용할 수 있습니다. 또한, Spring Security에서는 @PreAuthorize 어노테이션과 함께 SpEL을 사용하여 보안 규칙을 정의할 수 있습니다.

SpEL은 유연하고 강력한 표현 언어로서 Spring 프레임워크에서 다양한 상황에서 사용됩니다.

이를 통해 프레임워크의 설정, 보안, 데이터 처리 등을 더욱 효과적으로 처리할 수 있습니다.

 

참고자료: https://jaime-note.tistory.com/81

 

 

여기서 hasRole() 은 어떻게 확인이 될까?

hasRole() 함수는 Spring Security에서 제공하는 함수로, 현재 사용자가 특정 역할(role)을 가지고 있는지 확인하는 데 사용됩니다. 일반적으로 역할은 "ROLE_" 접두사를 붙여 정의됩니다. 

 

이러한 "ROLE_" 접두사를 사용하는 이유는 다음과 같습니다

  • 규칙과 가독성: "ROLE_" 접두사를 사용하여 역할을 정의하면, 역할 이름이 사용자 정의 역할과 구분되어 가독성이 높아집니다. 예를 들어, "ROLE_ADMIN"과 같이 접두사를 사용하면, 해당 역할이 시스템 관리자 역할임을 명확하게 알 수 있습니다.
  • Spring Security 요구 사항: Spring Security는 사용자의 역할을 확인할 때 "ROLE_" 접두사를 요구합니다. Spring Security는 내부에서 역할 이름을 검사할 때 이 접두사를 기대하므로, 역할 이름을 정의할 때 일관성을 유지하기 위해 "ROLE_" 접두사를 사용하는 것이 좋습니다.
  • 컨벤션과 호환성: "ROLE_" 접두사를 사용하여 역할을 정의하는 것은 일반적인 컨벤션입니다. 많은 Spring Security 개발자들이 이 컨벤션을 따르고 있으며, 이는 코드의 호환성과 유지 보수성을 높여줍니다.

따라서, hasRole() 함수를 사용하여 사용자의 역할을 확인할 때는 "ROLE_" 접두사를 사용하여 역할을 정의하는 것이 권장됩니다. 이를 통해 코드의 가독성과 일관성을 유지할 수 있고, Spring Security와의 호환성도 보장할 수 있습니다.

 

참고자료: https://docs.spring.io/spring-security/site/docs/4.0.x/apidocs/org/springframework/security/access/expression/SecurityExpressionRoot.html

 

아래 그림처럼 우리가 이전에 Authority를 저장할 때, ROLE_ 로 저장한 이유는 여기에 있습니다.

이로써, Spring Security에서는 ROLE_ 이 붙어 있을 때, 아 이게 역할에 해당한는 Authority 구나! 하고 판단을 하는거죠.

 

 

 

 

@PreAuthorize 와 @PostAuthorize 사용 예시

@PreAuthorize("#user.name == principal.name")
fun doSomething1(user: User): Unit { ... }

@PreAuthorize("hasRole('ADMIN') or hasRole('STUDENT')")
fun doSomething2(course: Course): Unit { ... }

@PostAuthorize("returnObject.owner == authentication.name")
fun getCustomer(val id: String): Customer { ... }

 

 

4. 인가 구현하기

많은 방법 중에서 우리는

@PreAuthorize 와 hasRole() 를 사용해 아래 그림처럼 Controller에 추가합니다.

 

5. 인가 예외 처리

만약 인가 예외 처리를 해주지 않을 경우 401("JWT verification failed")이 뜨게 된다.

왜냐하면 기본적으로 등록된 AuthenticationEntryPoint로 이 예외가 떨어졌기 때문이다.

 

따라서, 인가에서 발생한 예외인 AccessDeniedException 는 AccessDeniedHandler 가 담당하므로,

우리가 AccessDeniedHandler를 직접 커스텀 해서 Bean등록해주고 FilterChain에 적용해 주면 됩니다.

 

CustomAccessDeniedHandler 를 작성한다.

@Component
class CustomAccessDeniedHandler: AccessDeniedHandler {
    override fun handle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        accessDeniedException: AccessDeniedException
    ) {
        response.status = HttpServletResponse.SC_FORBIDDEN
        response.contentType = MediaType.APPLICATION_JSON_VALUE
        response.characterEncoding = "UTF-8"

        val objectMapper = ObjectMapper()
        val jsonString = objectMapper.writeValueAsString(ErrorResponse("No permission to run API"))
        response.writer.write(jsonString)
    }
}

이후 SecurityConfig 에 주입 해주고 연결만 해주면

403 Status Code가 정상적으로 오는걸 확인할 수 있다.

 

끝!

'📖 Study > Spring' 카테고리의 다른 글

Spring | 코드 기반으로 테스트 하기  (1) 2024.02.08
Spring | JWT 인증 구현하기  (2) 2024.02.01
Spring | 로그인 구현하기  (0) 2024.01.31
Spring | SecurityConfig, JwtPlugin  (1) 2024.01.30

블로그의 정보

꿈틀꿈틀 개발일기

jeongminy

활동하기