[#30] feat: 인증 여부 체크 API 도입 및 Security 관련 리팩토링

- 인증 여부 체크 API 개발(GET /auth/check)
- 인증, 인가 Exception 처리에 대한 Security 적용
- 기타 리팩토링
This commit is contained in:
Hanbin Lee
2023-01-20 01:57:12 +09:00
parent 6337320cae
commit 4c18940e86
15 changed files with 216 additions and 21 deletions

View File

@@ -1,5 +1,10 @@
package io.beaniejoy.dongnecafe.common.config
import io.beaniejoy.dongnecafe.security.JwtTokenUtils
import io.beaniejoy.dongnecafe.security.config.JwtAuthenticationConfigurer
import io.beaniejoy.dongnecafe.security.handler.CustomAccessDeniedHandler
import io.beaniejoy.dongnecafe.security.handler.CustomAuthenticationEntryPoint
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.autoconfigure.security.servlet.PathRequest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@@ -14,6 +19,15 @@ import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Autowired
lateinit var jwtTokenUtils: JwtTokenUtils
@Autowired
lateinit var customAccessDeniedHandler: CustomAccessDeniedHandler
@Autowired
lateinit var customAuthenticationEntryPoint: CustomAuthenticationEntryPoint
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http
@@ -28,10 +42,22 @@ class SecurityConfig {
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 방식(세션 불필요)
.and()
.also { jwtAuthenticationConfigurer(it) }
.exceptionHandling()
.authenticationEntryPoint(customAuthenticationEntryPoint) // 인증 예외 entryPoint 적용
.accessDeniedHandler(customAccessDeniedHandler) // 인가 예외 handler 적용
.and()
.build()
}
private fun jwtAuthenticationConfigurer(http: HttpSecurity) {
http
.apply(JwtAuthenticationConfigurer())
.jwtTokenUtils(jwtTokenUtils)
}
@Bean
fun webSecurityCustomizer(): WebSecurityCustomizer {
return WebSecurityCustomizer { web ->

View File

@@ -5,6 +5,9 @@ import io.beaniejoy.dongnecafe.security.JwtTokenUtils
import io.beaniejoy.dongnecafe.domain.member.model.request.SignInRequest
import io.beaniejoy.dongnecafe.model.TokenResponse
import io.beaniejoy.dongnecafe.service.AuthService
import mu.KLogging
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
@@ -16,6 +19,8 @@ class AuthController(
private val authService: AuthService,
private val jwtTokenUtils: JwtTokenUtils
) {
companion object: KLogging()
@PostMapping("/authenticate")
fun signIn(@RequestBody signInRequest: SignInRequest): ApplicationResponse<TokenResponse> {
val authentication = authService.signIn(
@@ -29,4 +34,13 @@ class AuthController(
.success("success authenticate")
.data(TokenResponse(accessToken))
}
@GetMapping("/check")
fun checkAuthenticated(@AuthenticationPrincipal principal: String?): ApplicationResponse<String> {
logger.info { "[Authentication Principal] $principal" }
return ApplicationResponse
.success("authenticated")
.data(principal)
}
}

View File

@@ -1,10 +1,9 @@
package io.beaniejoy.dongnecafe.security
import io.beaniejoy.dongnecafe.common.error.constant.ErrorCode
import io.beaniejoy.dongnecafe.common.error.exception.BusinessException
import io.beaniejoy.dongnecafe.security.SecurityUser
import mu.KLogging
import org.springframework.security.authentication.AuthenticationProvider
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.userdetails.UserDetailsService
@@ -30,7 +29,7 @@ class ApiAuthenticationProvider(
val user = userDetailsService.loadUserByUsername(email) as SecurityUser
if (!passwordEncoder.matches(password, user.password)) {
throw BusinessException(ErrorCode.AUTH_PASSWORD_NOT_VALID)
throw BadCredentialsException(ErrorCode.AUTH_PASSWORD_NOT_VALID.name)
}
logger.info { "User password ${user.password}" }

View File

@@ -4,10 +4,10 @@ import io.beaniejoy.dongnecafe.common.error.constant.ErrorCode
import io.beaniejoy.dongnecafe.common.error.exception.BusinessException
import io.beaniejoy.dongnecafe.domain.member.entity.Member
import io.beaniejoy.dongnecafe.domain.member.repository.MemberRepository
import io.beaniejoy.dongnecafe.security.SecurityUser
import mu.KLogging
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
@@ -22,7 +22,7 @@ class UserDetailsServiceImpl(
return memberRepository.findByEmail(email)?.let {
logger.info { "[LOAD MEMBER] email: ${it.email}, role: ${it.roleType}, activated: ${it.activated}" }
createSecurityUser(it)
} ?: throw BusinessException(ErrorCode.AUTH_MEMBER_NOT_FOUND)
} ?: throw UsernameNotFoundException(ErrorCode.AUTH_MEMBER_NOT_FOUND.name)
}
private fun createSecurityUser(member: Member): SecurityUser {

View File

@@ -0,0 +1,26 @@
package io.beaniejoy.dongnecafe.security.config
import io.beaniejoy.dongnecafe.security.JwtTokenUtils
import io.beaniejoy.dongnecafe.security.filter.JwtAuthenticationFilter
import org.springframework.security.config.annotation.SecurityConfigurerAdapter
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.DefaultSecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
class JwtAuthenticationConfigurer :
SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>() {
private lateinit var jwtTokenUtils: JwtTokenUtils
override fun configure(http: HttpSecurity) {
http
.addFilterBefore(
JwtAuthenticationFilter(this.jwtTokenUtils),
UsernamePasswordAuthenticationFilter::class.java
)
}
fun jwtTokenUtils(jwtTokenUtils: JwtTokenUtils): JwtAuthenticationConfigurer {
this.jwtTokenUtils = jwtTokenUtils
return this
}
}

View File

@@ -0,0 +1,52 @@
package io.beaniejoy.dongnecafe.security.filter
import io.beaniejoy.dongnecafe.security.JwtTokenUtils
import io.beaniejoy.dongnecafe.security.constant.SecurityConstant.BEARER
import io.beaniejoy.dongnecafe.security.constant.SecurityConstant.WHITESPACE
import mu.KotlinLogging
import org.springframework.http.HttpHeaders
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.filter.GenericFilterBean
import javax.servlet.FilterChain
import javax.servlet.ServletRequest
import javax.servlet.ServletResponse
import javax.servlet.http.HttpServletRequest
class JwtAuthenticationFilter(
private val jwtTokenUtils: JwtTokenUtils
) : GenericFilterBean() {
private val log = KotlinLogging.logger {}
/**
* JWT access token 인증 처리
*/
override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
val httpRequest = request as HttpServletRequest
log.info { "[JwtAuthenticationFilter][${request.dispatcherType}] uri: ${request.requestURI}" }
getAccessToken(httpRequest)?.let {
jwtTokenUtils.getAuthentication(it)
}?.also {
SecurityContextHolder.getContext().authentication = it
log.info { "Valid Access Token [${it.name}]" }
}
chain.doFilter(request, response)
}
private fun getAccessToken(request: HttpServletRequest): String? {
val bearer = request.getHeader(HttpHeaders.AUTHORIZATION)
?: return null
val splitBearer = bearer.split(WHITESPACE)
if (splitBearer.first() != BEARER) {
return null
}
if (splitBearer.size != 2 || splitBearer.last().isBlank()) {
return null
}
return splitBearer.last()
}
}

View File

@@ -0,0 +1,25 @@
package io.beaniejoy.dongnecafe.security.handler
import mu.KLogging
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerExceptionResolver
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@Component
class CustomAccessDeniedHandler(
private val handlerExceptionResolver: HandlerExceptionResolver
) : AccessDeniedHandler {
companion object : KLogging()
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException
) {
logger.info { "Access Denied!!!!!" }
handlerExceptionResolver.resolveException(request, response, null, accessDeniedException)
}
}

View File

@@ -0,0 +1,25 @@
package io.beaniejoy.dongnecafe.security.handler
import mu.KLogging
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerExceptionResolver
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@Component
class CustomAuthenticationEntryPoint(
private val handlerExceptionResolver: HandlerExceptionResolver
): AuthenticationEntryPoint {
companion object: KLogging()
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException
) {
logger.info { "Unauthorized Error!!" }
handlerExceptionResolver.resolveException(request, response, null, authException)
}
}

View File

@@ -5,6 +5,7 @@ import io.beaniejoy.dongnecafe.common.error.exception.BusinessException
import io.beaniejoy.dongnecafe.common.response.ApplicationResponse
import mu.KLogging
import org.springframework.http.HttpStatus
import org.springframework.security.core.AuthenticationException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice
@@ -21,12 +22,13 @@ class BasicControllerAdvice {
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ApplicationResponse<Nothing> {
logger.error { "[COMMON][${e.javaClass.simpleName}] $e" }
logger.error { "[COMMON][${e::class.simpleName}] $e" }
return ApplicationResponse.fail(errorCode = ErrorCode.COMMON_SERVER_ERROR).build()
}
/**
* 비즈니스 로직 상 에러 처리(예상 가능한 예외 처리)
* 비즈니스 로직 상 에러 처리(예상 가능한 예외 처리 - 200 ok 처리)
* - TODO: 우선 인증, 인가 관련 에러도 여기에 포함 - 분리할지 고민
* @param e BusinessException
*/
@ResponseStatus(HttpStatus.OK)
@@ -35,4 +37,22 @@ class BasicControllerAdvice {
logger.error { "[${BusinessException::class.simpleName}] <ErrorCode>: ${e.errorCode.name}, <ErrorMessage>: ${e.message}" }
return ApplicationResponse.fail(errorCode = e.errorCode).build()
}
/**
* 인증, 인가 관련 에러 처리
* - TODO: 이부분을 따로 구성해야할 필요성 아직 모르겠음
* - 현재로써는 BusinessException handler 내용과 똑같다고 볼 수 있지만 성격상 분리
*/
@ResponseStatus(HttpStatus.OK)
@ExceptionHandler(*arrayOf(AuthenticationException::class, AccessDeniedException::class))
fun handleAuthException(e: Exception): ApplicationResponse<Nothing> {
val errorCode = when (e) {
is AuthenticationException -> ErrorCode.AUTH_UNAUTHORIZED
is AccessDeniedException -> ErrorCode.AUTH_ACCESS_DENIED
else -> ErrorCode.DEFAULT
}
logger.error { "[${e::class.simpleName}] <ErrorCode>: ${errorCode.name}, <ErrorMessage>: ${e.message}" }
return ApplicationResponse.fail(errorCode = errorCode).build()
}
}

View File

@@ -5,15 +5,18 @@ import io.beaniejoy.dongnecafe.common.error.constant.SubCategory.*
enum class ErrorCode(
val domain: Domain,
val subCategory: SubCategory
val subCategory: SubCategory?
) {
// COMMON
COMMON_SERVER_ERROR(COMMON, SERVER_ERROR),
DEFAULT(COMMON, null),
// AUTH(security 관련)
AUTH_MEMBER_NOT_FOUND(AUTH, INVALID_AUTHENTICATE_REQUEST),
AUTH_PASSWORD_NOT_VALID(AUTH, INVALID_AUTHENTICATE_REQUEST),
AUTH_MEMBER_DEACTIVATED(AUTH, DEACTIVATED),
AUTH_ACCESS_DENIED(AUTH, ACCESS_DENIED),
AUTH_UNAUTHORIZED(AUTH, UNAUTHORIZED),
// MEMBER
MEMBER_EXISTED(MEMBER, EXISTED),
@@ -29,7 +32,6 @@ enum class ErrorCode(
MENU_OPTION_NOT_FOUND(MENU_OPTION, NOT_FOUND),
// OPTION_DETAIL
OPTION_DETAIL_NOT_FOUND(OPTION_DETAIL, NOT_FOUND)
OPTION_DETAIL_NOT_FOUND(OPTION_DETAIL, NOT_FOUND),
;
}

View File

@@ -5,5 +5,8 @@ enum class SubCategory {
INVALID_AUTHENTICATE_REQUEST,
NOT_FOUND,
EXISTED,
DEACTIVATED
DEACTIVATED,
ACCESS_DENIED,
UNAUTHORIZED
}

View File

@@ -0,0 +1,6 @@
package io.beaniejoy.dongnecafe.security.constant
object SecurityConstant {
const val BEARER = "Bearer"
const val WHITESPACE = " "
}

View File

@@ -26,7 +26,7 @@ class SecurityConfig {
// FIXME 임시 permitAll 설정
.authorizeRequests()
.anyRequest().permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()

View File

@@ -8,12 +8,12 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
class JwtAuthenticationConfigurer :
SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>() {
private var jwtTokenUtils: JwtTokenUtils? = null
private lateinit var jwtTokenUtils: JwtTokenUtils
override fun configure(http: HttpSecurity) {
http
.addFilterBefore(
JwtAuthenticationFilter(this.jwtTokenUtils!!),
JwtAuthenticationFilter(this.jwtTokenUtils),
UsernamePasswordAuthenticationFilter::class.java
)
}

View File

@@ -1,7 +1,10 @@
package io.beaniejoy.dongnecafe.security.filter
import io.beaniejoy.dongnecafe.security.JwtTokenUtils
import io.beaniejoy.dongnecafe.security.constant.SecurityConstant.BEARER
import io.beaniejoy.dongnecafe.security.constant.SecurityConstant.WHITESPACE
import mu.KotlinLogging
import org.springframework.http.HttpHeaders
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.filter.GenericFilterBean
import javax.servlet.FilterChain
@@ -14,12 +17,6 @@ class JwtAuthenticationFilter(
) : GenericFilterBean() {
private val log = KotlinLogging.logger {}
companion object {
private const val AUTHORIZATION = "Authorization"
private const val BEARER = "Bearer"
private const val WHITESPACE = " "
}
/**
* JWT access token 인가 처리
*/
@@ -38,7 +35,7 @@ class JwtAuthenticationFilter(
}
private fun getAccessToken(request: HttpServletRequest): String? {
val bearer = request.getHeader(AUTHORIZATION)
val bearer = request.getHeader(HttpHeaders.AUTHORIZATION)
?: return null
val splitBearer = bearer.split(WHITESPACE)