diff --git a/src/main/kotlin/com/moa/common/auth/Auth.kt b/src/main/kotlin/com/moa/common/auth/Auth.kt index 3cc9d19..58095db 100644 --- a/src/main/kotlin/com/moa/common/auth/Auth.kt +++ b/src/main/kotlin/com/moa/common/auth/Auth.kt @@ -3,7 +3,3 @@ package com.moa.common.auth @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) annotation class Auth - -data class AuthenticatedMemberInfo( - val id: Long, -) diff --git a/src/main/kotlin/com/moa/common/auth/AuthConstants.kt b/src/main/kotlin/com/moa/common/auth/AuthConstants.kt deleted file mode 100644 index d7460a5..0000000 --- a/src/main/kotlin/com/moa/common/auth/AuthConstants.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.moa.common.auth - -object AuthConstants { - const val CURRENT_MEMBER_ID = "currentMemberId" -} diff --git a/src/main/kotlin/com/moa/common/auth/AuthMemberInfo.kt b/src/main/kotlin/com/moa/common/auth/AuthMemberInfo.kt new file mode 100644 index 0000000..afcf1fb --- /dev/null +++ b/src/main/kotlin/com/moa/common/auth/AuthMemberInfo.kt @@ -0,0 +1,5 @@ +package com.moa.common.auth + +data class AuthMemberInfo( + val id: Long, +) diff --git a/src/main/kotlin/com/moa/common/auth/AuthMemberResolver.kt b/src/main/kotlin/com/moa/common/auth/AuthMemberResolver.kt new file mode 100644 index 0000000..9bb825f --- /dev/null +++ b/src/main/kotlin/com/moa/common/auth/AuthMemberResolver.kt @@ -0,0 +1,105 @@ +package com.moa.common.auth + +import com.moa.common.exception.ErrorCode +import com.moa.common.exception.ForbiddenException +import com.moa.common.exception.UnauthorizedException +import com.moa.repository.* +import io.jsonwebtoken.ExpiredJwtException +import jakarta.servlet.http.HttpServletRequest +import org.springframework.core.MethodParameter +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import java.time.LocalDate + +@Component +class AuthMemberResolver( + private val jwtTokenProvider: JwtTokenProvider, + private val request: HttpServletRequest, + private val termRepository: TermRepository, + private val termAgreementRepository: TermAgreementRepository, + private val profileRepository: ProfileRepository, + private val payrollVersionRepository: PayrollVersionRepository, + private val workPolicyVersionRepository: WorkPolicyVersionRepository, +) : HandlerMethodArgumentResolver { + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.hasParameterAnnotation(Auth::class.java) && + parameter.parameterType == AuthMemberInfo::class.java + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): AuthMemberInfo { + val memberId = resolveMemberId() + validateOnboardingCompleted(memberId) + return AuthMemberInfo(id = memberId) + } + + private fun resolveMemberId(): Long { + val token = jwtTokenProvider.extractToken(request) + ?: throw UnauthorizedException() + + return try { + jwtTokenProvider.validateToken(token) + jwtTokenProvider.getUserIdFromToken(token) + } catch (ex: ExpiredJwtException) { + throw UnauthorizedException(ErrorCode.EXPIRED_TOKEN) + } catch (ex: Exception) { + throw UnauthorizedException() + } ?: throw UnauthorizedException() + } + + private fun validateOnboardingCompleted(memberId: Long) { + val today = LocalDate.now() + + val profileCompleted = isProfileCompleted(memberId) + val payrollCompleted = isPayrollCompleted(memberId, today) + val workPolicyCompleted = isWorkPolicyCompleted(memberId, today) + val hasRequiredTermsAgreed = hasRequiredTermsAgreed(memberId) + + val onboardingCompleted = + profileCompleted && payrollCompleted && workPolicyCompleted && hasRequiredTermsAgreed + + if (!onboardingCompleted) { + throw ForbiddenException(ErrorCode.ONBOARDING_INCOMPLETE) + } + } + + private fun isProfileCompleted(memberId: Long): Boolean { + return profileRepository.findByMemberId(memberId) + ?.let { it.nickname.isNotBlank() && it.workplace.isNotBlank() } + ?: false + } + + private fun isPayrollCompleted(memberId: Long, today: LocalDate): Boolean { + return payrollVersionRepository + .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc(memberId, today) != null + } + + private fun isWorkPolicyCompleted(memberId: Long, today: LocalDate): Boolean { + return workPolicyVersionRepository + .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc(memberId, today) + ?.workdays + ?.isNotEmpty() + ?: false + } + + private fun hasRequiredTermsAgreed(memberId: Long): Boolean { + val requiredCodes = termRepository.findAll() + .asSequence() + .filter { it.required } + .map { it.code } + .toSet() + + val agreements = termAgreementRepository.findAllByMemberId(memberId) + .associate { it.termCode to it.agreed } + + return requiredCodes.all { agreements[it] == true } + } +} diff --git a/src/main/kotlin/com/moa/common/auth/AuthenticatedMemberResolver.kt b/src/main/kotlin/com/moa/common/auth/AuthenticatedMemberResolver.kt deleted file mode 100644 index 93a0f00..0000000 --- a/src/main/kotlin/com/moa/common/auth/AuthenticatedMemberResolver.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.moa.common.auth - -import com.moa.common.exception.BadRequestException -import com.moa.common.exception.ErrorCode -import org.springframework.core.MethodParameter -import org.springframework.stereotype.Component -import org.springframework.web.bind.support.WebDataBinderFactory -import org.springframework.web.context.request.NativeWebRequest -import org.springframework.web.context.request.RequestAttributes -import org.springframework.web.method.support.HandlerMethodArgumentResolver -import org.springframework.web.method.support.ModelAndViewContainer - -@Component -class AuthenticatedMemberResolver : HandlerMethodArgumentResolver { - - override fun supportsParameter(parameter: MethodParameter): Boolean { - return parameter.hasParameterAnnotation(Auth::class.java) && - parameter.parameterType == AuthenticatedMemberInfo::class.java - } - - override fun resolveArgument( - parameter: MethodParameter, - mavContainer: ModelAndViewContainer?, - webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory?, - ): AuthenticatedMemberInfo { - val memberId = webRequest.getAttribute( - AuthConstants.CURRENT_MEMBER_ID, - RequestAttributes.SCOPE_REQUEST - ) as? Long ?: throw BadRequestException(ErrorCode.INVALID_ID_TOKEN) - - return AuthenticatedMemberInfo(id = memberId) - } -} diff --git a/src/main/kotlin/com/moa/common/auth/OnboardingAuth.kt b/src/main/kotlin/com/moa/common/auth/OnboardingAuth.kt new file mode 100644 index 0000000..c43d1e5 --- /dev/null +++ b/src/main/kotlin/com/moa/common/auth/OnboardingAuth.kt @@ -0,0 +1,5 @@ +package com.moa.common.auth + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class OnboardingAuth diff --git a/src/main/kotlin/com/moa/common/auth/OnboardingAuthMemberResolver.kt b/src/main/kotlin/com/moa/common/auth/OnboardingAuthMemberResolver.kt new file mode 100644 index 0000000..6a8db67 --- /dev/null +++ b/src/main/kotlin/com/moa/common/auth/OnboardingAuthMemberResolver.kt @@ -0,0 +1,47 @@ +package com.moa.common.auth + +import com.moa.common.exception.ErrorCode +import com.moa.common.exception.UnauthorizedException +import io.jsonwebtoken.ExpiredJwtException +import jakarta.servlet.http.HttpServletRequest +import org.springframework.core.MethodParameter +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +@Component +class OnboardingAuthMemberResolver( + private val jwtTokenProvider: JwtTokenProvider, + private val request: HttpServletRequest, +) : HandlerMethodArgumentResolver { + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.hasParameterAnnotation(OnboardingAuth::class.java) && + parameter.parameterType == AuthMemberInfo::class.java + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): AuthMemberInfo { + val token = jwtTokenProvider.extractToken(request) + ?: throw UnauthorizedException() + + try { + jwtTokenProvider.validateToken(token) + + val memberId = jwtTokenProvider.getUserIdFromToken(token) + ?: throw UnauthorizedException() + + return AuthMemberInfo(id = memberId) + } catch (ex: ExpiredJwtException) { + throw UnauthorizedException(ErrorCode.EXPIRED_TOKEN) + } catch (ex: Exception) { + throw UnauthorizedException() + } + } +} diff --git a/src/main/kotlin/com/moa/common/config/SwaggerConfig.kt b/src/main/kotlin/com/moa/common/config/SwaggerConfig.kt index 23646ab..8d81df2 100644 --- a/src/main/kotlin/com/moa/common/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/moa/common/config/SwaggerConfig.kt @@ -1,6 +1,7 @@ package com.moa.common.config import com.moa.common.auth.Auth +import com.moa.common.auth.OnboardingAuth import io.swagger.v3.oas.models.Components import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.info.Info @@ -15,7 +16,9 @@ import org.springframework.context.annotation.Configuration class SwaggerConfig { init { - SpringDocUtils.getConfig().addAnnotationsToIgnore(Auth::class.java) + SpringDocUtils.getConfig() + .addAnnotationsToIgnore(OnboardingAuth::class.java) + .addAnnotationsToIgnore(Auth::class.java) } @Bean diff --git a/src/main/kotlin/com/moa/common/config/WebConfig.kt b/src/main/kotlin/com/moa/common/config/WebConfig.kt index 64b3c8c..e3b0c08 100644 --- a/src/main/kotlin/com/moa/common/config/WebConfig.kt +++ b/src/main/kotlin/com/moa/common/config/WebConfig.kt @@ -1,16 +1,19 @@ package com.moa.common.config -import com.moa.common.auth.AuthenticatedMemberResolver +import com.moa.common.auth.AuthMemberResolver +import com.moa.common.auth.OnboardingAuthMemberResolver import org.springframework.context.annotation.Configuration import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @Configuration class WebConfig( - private val authenticatedMemberResolver: AuthenticatedMemberResolver, + private val onboardingAuthMemberResolver: OnboardingAuthMemberResolver, + private val authMemberResolver: AuthMemberResolver, ) : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { - resolvers.add(authenticatedMemberResolver) + resolvers.add(onboardingAuthMemberResolver) + resolvers.add(authMemberResolver) } } diff --git a/src/main/kotlin/com/moa/common/exception/ErrorCode.kt b/src/main/kotlin/com/moa/common/exception/ErrorCode.kt index 64cdfcd..4b37e72 100644 --- a/src/main/kotlin/com/moa/common/exception/ErrorCode.kt +++ b/src/main/kotlin/com/moa/common/exception/ErrorCode.kt @@ -10,7 +10,7 @@ enum class ErrorCode( // 4xx BAD_REQUEST("BAD_REQUEST", "잘못된 요청입니다."), UNAUTHORIZED("UNAUTHORIZED", "인증되지 않은 사용자입니다"), - FORBIDDEN("FORBIDDEN", "권한이 없습니다"), + ONBOARDING_INCOMPLETE("ONBOARDING_INCOMPLETE", "온보딩이 완료되지 않았습니다"), RESOURCE_NOT_FOUND("RESOURCE_NOT_FOUND", "리소스를 찾을 수 없습니다"), INVALID_PAYROLL_INPUT("INVALID_PAYROLL_INPUT", "급여 입력값이 유효하지 않습니다"), diff --git a/src/main/kotlin/com/moa/common/exception/ForbiddenException.kt b/src/main/kotlin/com/moa/common/exception/ForbiddenException.kt index d65846a..9c59d73 100644 --- a/src/main/kotlin/com/moa/common/exception/ForbiddenException.kt +++ b/src/main/kotlin/com/moa/common/exception/ForbiddenException.kt @@ -1,5 +1,5 @@ package com.moa.common.exception class ForbiddenException( - val errorCode: ErrorCode = ErrorCode.FORBIDDEN, + val errorCode: ErrorCode, ) : RuntimeException(errorCode.message) diff --git a/src/main/kotlin/com/moa/common/filter/JwtAuthenticationFilter.kt b/src/main/kotlin/com/moa/common/filter/JwtAuthenticationFilter.kt deleted file mode 100644 index 4cb875f..0000000 --- a/src/main/kotlin/com/moa/common/filter/JwtAuthenticationFilter.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.moa.common.filter - -import com.moa.common.auth.AuthConstants -import com.moa.common.auth.JwtTokenProvider -import com.moa.common.exception.ErrorCode -import io.jsonwebtoken.ExpiredJwtException -import jakarta.servlet.FilterChain -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.MediaType -import org.springframework.stereotype.Component -import org.springframework.web.filter.OncePerRequestFilter -import tools.jackson.databind.ObjectMapper - -@Component -class JwtAuthenticationFilter( - private val jwtTokenProvider: JwtTokenProvider, - private val objectMapper: ObjectMapper, -) : OncePerRequestFilter() { - - companion object { - private val EXCLUDED_PATHS = listOf( - "/api/v1/auth", - "/h2-console", - "/api-docs-ui", - "/swagger-ui", - "/swagger-resources", - "/v3/api-docs", - ) - } - - override fun shouldNotFilter(request: HttpServletRequest): Boolean { - val path = request.requestURI - return EXCLUDED_PATHS.any { path.startsWith(it) } - } - - override fun doFilterInternal( - request: HttpServletRequest, - response: HttpServletResponse, - filterChain: FilterChain, - ) { - val token = jwtTokenProvider.extractToken(request) - - if (token == null) { - writeErrorResponse(response, ErrorCode.UNAUTHORIZED) - return - } - - try { - jwtTokenProvider.validateToken(token) - - val memberId = jwtTokenProvider.getUserIdFromToken(token) - if (memberId == null) { - writeErrorResponse(response, ErrorCode.UNAUTHORIZED) - return - } - request.setAttribute(AuthConstants.CURRENT_MEMBER_ID, memberId) - filterChain.doFilter(request, response) - } catch (ex: ExpiredJwtException) { - writeErrorResponse(response, ErrorCode.EXPIRED_TOKEN) - } catch (ex: Exception) { - writeErrorResponse(response, ErrorCode.UNAUTHORIZED) - } - } - - private fun writeErrorResponse(response: HttpServletResponse, errorCode: ErrorCode) { - response.status = HttpServletResponse.SC_UNAUTHORIZED - response.contentType = MediaType.APPLICATION_JSON_VALUE - response.characterEncoding = "UTF-8" - - val errorResponse = mapOf( - "code" to errorCode.code, - "message" to errorCode.message, - "content" to null - ) - - objectMapper.writeValue(response.writer, errorResponse) - } -} diff --git a/src/main/kotlin/com/moa/controller/OnboardingController.kt b/src/main/kotlin/com/moa/controller/OnboardingController.kt index d8a38d8..b555aaf 100644 --- a/src/main/kotlin/com/moa/controller/OnboardingController.kt +++ b/src/main/kotlin/com/moa/controller/OnboardingController.kt @@ -1,7 +1,7 @@ package com.moa.controller -import com.moa.common.auth.Auth -import com.moa.common.auth.AuthenticatedMemberInfo +import com.moa.common.auth.AuthMemberInfo +import com.moa.common.auth.OnboardingAuth import com.moa.common.response.ApiResponse import com.moa.service.* import com.moa.service.dto.PayrollUpsertRequest @@ -22,24 +22,24 @@ class OnboardingController( ) { @GetMapping("/status") - fun status(@Auth member: AuthenticatedMemberInfo) = + fun status(@OnboardingAuth member: AuthMemberInfo) = ApiResponse.success(onboardingStatusService.getStatus(member.id)) @PatchMapping("/profile") fun upsertProfile( - @Auth member: AuthenticatedMemberInfo, + @OnboardingAuth member: AuthMemberInfo, @RequestBody @Valid req: ProfileUpsertRequest, ) = ApiResponse.success(profileService.upsertProfile(member.id, req)) @PatchMapping("/payroll") fun upsertPayroll( - @Auth member: AuthenticatedMemberInfo, + @OnboardingAuth member: AuthMemberInfo, @RequestBody @Valid req: PayrollUpsertRequest, ) = ApiResponse.success(payrollService.upsert(member.id, req)) @PatchMapping("/work-policy") fun upsertWorkPolicy( - @Auth member: AuthenticatedMemberInfo, + @OnboardingAuth member: AuthMemberInfo, @RequestBody @Valid req: WorkPolicyUpsertRequest, ) = ApiResponse.success(workPolicyService.upsert(member.id, req)) @@ -48,12 +48,12 @@ class OnboardingController( ApiResponse.success(termsService.getTerms()) @GetMapping("/terms/agreements") - fun agreements(@Auth member: AuthenticatedMemberInfo) = + fun agreements(@OnboardingAuth member: AuthMemberInfo) = ApiResponse.success(termsService.getAgreements(member.id)) @PutMapping("/terms/agreements") fun agree( - @Auth member: AuthenticatedMemberInfo, + @OnboardingAuth member: AuthMemberInfo, @RequestBody @Valid req: TermsAgreementRequest, ) = ApiResponse.success(termsService.upsertAgreements(member.id, req)) }