Spring | 인증과 인가 및 예외 처리
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와의 호환성도 보장할 수 있습니다.
아래 그림처럼 우리가 이전에 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가 정상적으로 오는걸 확인할 수 있다.
끝!