diff --git a/.github/workflows/be-pr-workflow.yml b/.github/workflows/pr-workflow.yml similarity index 91% rename from .github/workflows/be-pr-workflow.yml rename to .github/workflows/pr-workflow.yml index 185c1ee..28a6e2f 100644 --- a/.github/workflows/be-pr-workflow.yml +++ b/.github/workflows/pr-workflow.yml @@ -22,6 +22,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_TOKEN }} - name: Set up JDK 21 uses: actions/setup-java@v4 diff --git a/.github/workflows/be-deploy.yml b/.github/workflows/prod-deploy.yml similarity index 95% rename from .github/workflows/be-deploy.yml rename to .github/workflows/prod-deploy.yml index 030d179..32c5094 100644 --- a/.github/workflows/be-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -21,7 +21,8 @@ jobs: - name: Checkout (for tagging) uses: actions/checkout@v4 with: - fetch-depth: 0 + submodules: recursive + token: ${{ secrets.SUBMODULE_TOKEN }} - name: Calculate version id: calc @@ -94,5 +95,5 @@ jobs: --name moa-test-server \ --restart unless-stopped \ -p 8080:8080 \ - -e PROFILE= \ + -e PROFILE=prod\ -d godqhr721/moa_server:${{ needs.setup.outputs.docker_tag }} diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b1e5601 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/main/resources/moa-secret"] + path = src/main/resources/moa-secret + url = https://github.com/subsub97/moa-secret diff --git a/build.gradle.kts b/build.gradle.kts index 03891b8..68e873b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,7 +31,12 @@ dependencies { // db implementation("org.springframework.boot:spring-boot-h2console") runtimeOnly("com.h2database:h2") - // runtimeOnly("com.mysql:mysql-connector-j") + runtimeOnly("com.mysql:mysql-connector-j") + + //jwt + implementation("io.jsonwebtoken:jjwt-api:0.13.0") + implementation("io.jsonwebtoken:jjwt-impl:0.13.0") + implementation("io.jsonwebtoken:jjwt-jackson:0.13.0") // etc implementation("org.jetbrains.kotlin:kotlin-reflect") diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/moa/Application.kt b/src/main/kotlin/com/moa/Application.kt index 247c13e..a87e2c6 100644 --- a/src/main/kotlin/com/moa/Application.kt +++ b/src/main/kotlin/com/moa/Application.kt @@ -1,9 +1,11 @@ package com.moa import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication @SpringBootApplication +@ConfigurationPropertiesScan class Application fun main(args: Array) { diff --git a/src/main/kotlin/com/moa/common/auth/AuthConstants.kt b/src/main/kotlin/com/moa/common/auth/AuthConstants.kt new file mode 100644 index 0000000..d7460a5 --- /dev/null +++ b/src/main/kotlin/com/moa/common/auth/AuthConstants.kt @@ -0,0 +1,5 @@ +package com.moa.common.auth + +object AuthConstants { + const val CURRENT_MEMBER_ID = "currentMemberId" +} diff --git a/src/main/kotlin/com/moa/common/auth/AuthenticatedMember.kt b/src/main/kotlin/com/moa/common/auth/AuthenticatedMember.kt new file mode 100644 index 0000000..3c64d5b --- /dev/null +++ b/src/main/kotlin/com/moa/common/auth/AuthenticatedMember.kt @@ -0,0 +1,9 @@ +package com.moa.common.auth + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class AuthenticatedMember + +data class AuthenticatedMemberInfo( + val id: Long, +) diff --git a/src/main/kotlin/com/moa/common/auth/AuthenticatedMemberResolver.kt b/src/main/kotlin/com/moa/common/auth/AuthenticatedMemberResolver.kt new file mode 100644 index 0000000..9a21ee6 --- /dev/null +++ b/src/main/kotlin/com/moa/common/auth/AuthenticatedMemberResolver.kt @@ -0,0 +1,34 @@ +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(AuthenticatedMember::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/JwtTokenProvider.kt b/src/main/kotlin/com/moa/common/auth/JwtTokenProvider.kt new file mode 100644 index 0000000..bfe14f8 --- /dev/null +++ b/src/main/kotlin/com/moa/common/auth/JwtTokenProvider.kt @@ -0,0 +1,69 @@ +package com.moa.common.auth + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import jakarta.servlet.http.HttpServletRequest +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +@Component +class JwtTokenProvider( + @Value("\${jwt.secret-key}") + private val accessTokenSecretKey: String, + + @Value("\${jwt.expiration-milliseconds}") + private val accessTokenExpirationInMilliseconds: Long, +) { + + private val accessKey = Keys.hmacShaKeyFor(accessTokenSecretKey.toByteArray(StandardCharsets.UTF_8)) + + fun createAccessToken(userId: Long): String { + val now = LocalDateTime.now() + val expiryDate = now.plus(Duration.ofMillis(accessTokenExpirationInMilliseconds)) + + return Jwts.builder() + .subject(userId.toString()) + .issuedAt(toDate(now)) + .expiration(toDate(expiryDate)) + .signWith(accessKey) + .compact() + } + + fun extractToken(request: HttpServletRequest): String? { + val bearerToken = request.getHeader("Authorization") + return if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + bearerToken.substring(7) + } else null + } + + fun getUserIdFromToken(token: String): Long? { + return getClaims(token).subject.toLong() + } + + fun validateToken(token: String): Boolean { + return try { + getClaims(token) + true + } catch (ex: Exception) { + false + } + } + + private fun getClaims(token: String): Claims { + return Jwts.parser() + .verifyWith(accessKey) + .build() + .parseSignedClaims(token) + .payload + } +} + +fun toDate(localDateTime: LocalDateTime): Date { + return Date.from(localDateTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()) +} diff --git a/src/main/kotlin/com/moa/common/config/WebConfig.kt b/src/main/kotlin/com/moa/common/config/WebConfig.kt new file mode 100644 index 0000000..64b3c8c --- /dev/null +++ b/src/main/kotlin/com/moa/common/config/WebConfig.kt @@ -0,0 +1,16 @@ +package com.moa.common.config + +import com.moa.common.auth.AuthenticatedMemberResolver +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, +) : WebMvcConfigurer { + + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(authenticatedMemberResolver) + } +} diff --git a/src/main/kotlin/com/moa/common/exception/ErrorCode.kt b/src/main/kotlin/com/moa/common/exception/ErrorCode.kt index 3e3ac06..6f65a9f 100644 --- a/src/main/kotlin/com/moa/common/exception/ErrorCode.kt +++ b/src/main/kotlin/com/moa/common/exception/ErrorCode.kt @@ -16,4 +16,8 @@ enum class ErrorCode( INVALID_PAYROLL_INPUT("INVALID_PAYROLL_INPUT", "급여 입력값이 유효하지 않습니다"), INVALID_WORK_POLICY_INPUT("INVALID_WORK_POLICY_INPUT", "근무정책 입력값이 유효하지 않습니다"), REQUIRED_TERMS_MUST_BE_AGREED("REQUIRED_TERMS_MUST_BE_AGREED", "필수 약관은 동의해야 합니다"), + + INVALID_ID_TOKEN("INVALID_ID_TOKEN", "유효하지 않은 ID 토큰입니다"), + INVALID_PROVIDER("INVALID_PROVIDER", "유효하지 않는 로그인 방식입니다."), + OIDC_PROVIDER_ERROR("OIDC_PROVIDER_ERROR", "인증 제공자 연동 중 오류가 발생했습니다"), } diff --git a/src/main/kotlin/com/moa/common/exception/GlobalExceptionHandler.kt b/src/main/kotlin/com/moa/common/exception/GlobalExceptionHandler.kt index 4255a7c..e357102 100644 --- a/src/main/kotlin/com/moa/common/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/moa/common/exception/GlobalExceptionHandler.kt @@ -2,6 +2,7 @@ package com.moa.common.exception import com.moa.common.response.ApiResponse import com.moa.common.response.FieldError +import org.slf4j.LoggerFactory import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.HttpStatusCode @@ -15,6 +16,8 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExcep @RestControllerAdvice class GlobalExceptionHandler : ResponseEntityExceptionHandler() { + private val log = LoggerFactory.getLogger(this::class.java) + @ExceptionHandler(NotFoundException::class) fun handleNotFoundException(ex: NotFoundException): ResponseEntity> { return ResponseEntity @@ -43,6 +46,15 @@ class GlobalExceptionHandler : ResponseEntityExceptionHandler() { .body(ApiResponse.error(ex.errorCode)) } + @ExceptionHandler(RuntimeException::class) + fun handleRuntimeException(ex: RuntimeException): ResponseEntity> { + log.error("Unhandled Exception occurred: ${ex.message}", ex) + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR)) + } + override fun handleMethodArgumentNotValid( ex: MethodArgumentNotValidException, headers: HttpHeaders, diff --git a/src/main/kotlin/com/moa/common/filter/JwtAuthenticationFilter.kt b/src/main/kotlin/com/moa/common/filter/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..4aa8827 --- /dev/null +++ b/src/main/kotlin/com/moa/common/filter/JwtAuthenticationFilter.kt @@ -0,0 +1,68 @@ +package com.moa.common.filter + +import com.moa.common.auth.AuthConstants +import com.moa.common.auth.JwtTokenProvider +import com.moa.common.exception.ErrorCode +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", + ) + } + + 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 || !jwtTokenProvider.validateToken(token)) { + writeUnauthorizedResponse(response) + return + } + + val memberId = jwtTokenProvider.getUserIdFromToken(token) + if (memberId == null) { + writeUnauthorizedResponse(response) + return + } + + request.setAttribute(AuthConstants.CURRENT_MEMBER_ID, memberId) + filterChain.doFilter(request, response) + } + + private fun writeUnauthorizedResponse(response: HttpServletResponse) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = "UTF-8" + + val errorCode = ErrorCode.UNAUTHORIZED + 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/common/oidc/OidcClient.kt b/src/main/kotlin/com/moa/common/oidc/OidcClient.kt new file mode 100644 index 0000000..34b65bb --- /dev/null +++ b/src/main/kotlin/com/moa/common/oidc/OidcClient.kt @@ -0,0 +1,52 @@ +package com.moa.common.oidc + +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import java.math.BigInteger +import java.security.KeyFactory +import java.security.interfaces.RSAPublicKey +import java.security.spec.RSAPublicKeySpec +import java.util.* + +@Component +class OidcClient( + private val restClient: RestClient = RestClient.create(), +) { + fun fetchPublicKeys(jwksUri: String): Map { + val response = try { + restClient.get() + .uri(jwksUri) + .retrieve() + .body(JwksResponse::class.java) + } catch (ex: Exception) { + throw RuntimeException("OIDC 공개키를 가져오는데 실패했습니다.", ex) + } + + return response?.keys + ?.filter { it.kty == "RSA" && it.use == "sig" } + ?.associate { key -> + key.kid to createRsaPublicKey(key.n, key.e) + } ?: emptyMap() + } + + private fun createRsaPublicKey(n: String, e: String): RSAPublicKey { + val decoder = Base64.getUrlDecoder() + val modulus = BigInteger(1, decoder.decode(n)) + val exponent = BigInteger(1, decoder.decode(e)) + val spec = RSAPublicKeySpec(modulus, exponent) + val keyFactory = KeyFactory.getInstance("RSA") + return keyFactory.generatePublic(spec) as RSAPublicKey + } + + private data class JwksResponse( + val keys: List, + ) + + private data class JwkKey( + val kid: String, + val kty: String, + val use: String?, + val n: String, + val e: String, + ) +} diff --git a/src/main/kotlin/com/moa/common/oidc/OidcIdTokenValidator.kt b/src/main/kotlin/com/moa/common/oidc/OidcIdTokenValidator.kt new file mode 100644 index 0000000..994ce0d --- /dev/null +++ b/src/main/kotlin/com/moa/common/oidc/OidcIdTokenValidator.kt @@ -0,0 +1,74 @@ +package com.moa.common.oidc + +import com.moa.common.exception.ErrorCode +import com.moa.common.exception.UnauthorizedException +import com.moa.entity.ProviderType +import io.jsonwebtoken.Jwts +import org.springframework.stereotype.Component +import tools.jackson.databind.ObjectMapper +import java.util.* + +@Component +class OidcIdTokenValidator( + private val config: OidcProviderConfig, + private val publicKeyCache: OidcPublicKeyCache, + private val objectMapper: ObjectMapper +) { + fun validate(provider: ProviderType, idToken: String): OidcUserInfo { + val providerProperties = getProviderProperties(provider) + + return validateToken(idToken, providerProperties, provider) + } + + private fun getProviderProperties(provider: ProviderType): OidcProviderConfig.ProviderProperties { + return when (provider) { + ProviderType.KAKAO -> config.kakao + else -> throw UnauthorizedException(ErrorCode.INVALID_PROVIDER) + } + } + + private fun validateToken( + idToken: String, + providerConfig: OidcProviderConfig.ProviderProperties, + provider: ProviderType + ): OidcUserInfo { + val kid = extractKid(idToken) + + val publicKey = publicKeyCache.getPublicKey( + jwksUri = providerConfig.jwksUri, + kid = kid, + ttlSeconds = providerConfig.cacheTtlSeconds, + ) + + val claims = try { + Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(idToken) + .payload + } catch (ex: Exception) { + throw UnauthorizedException(ErrorCode.INVALID_ID_TOKEN) + } + + return OidcUserInfo( + subject = claims.subject, + provider = provider + ) + } + + + private fun extractKid(idToken: String): String { + try { + val headerPart = idToken.split(".")[0] + val decodedHeader = String(Base64.getUrlDecoder().decode(headerPart)) + + val headerMap = objectMapper.readValue(decodedHeader, Map::class.java) + + return headerMap["kid"] as? String + ?: throw UnauthorizedException(ErrorCode.INVALID_ID_TOKEN) + + } catch (ex: Exception) { + throw UnauthorizedException(ErrorCode.INVALID_ID_TOKEN) + } + } +} diff --git a/src/main/kotlin/com/moa/common/oidc/OidcProviderConfig.kt b/src/main/kotlin/com/moa/common/oidc/OidcProviderConfig.kt new file mode 100644 index 0000000..00306d7 --- /dev/null +++ b/src/main/kotlin/com/moa/common/oidc/OidcProviderConfig.kt @@ -0,0 +1,13 @@ +package com.moa.common.oidc + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oidc") +data class OidcProviderConfig( + val kakao: ProviderProperties, +) { + data class ProviderProperties( + val jwksUri: String, + val cacheTtlSeconds: Long = 3600, + ) +} diff --git a/src/main/kotlin/com/moa/common/oidc/OidcPublicKeyCache.kt b/src/main/kotlin/com/moa/common/oidc/OidcPublicKeyCache.kt new file mode 100644 index 0000000..6022c46 --- /dev/null +++ b/src/main/kotlin/com/moa/common/oidc/OidcPublicKeyCache.kt @@ -0,0 +1,40 @@ +package com.moa.common.oidc + +import com.moa.common.exception.BadRequestException +import com.moa.common.exception.ErrorCode +import org.springframework.stereotype.Component +import java.security.interfaces.RSAPublicKey +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap + +@Component +class OidcPublicKeyCache( + private val oidcClient: OidcClient, +) { + private data class CacheEntry( + val keys: Map, + val expiresAt: Instant, + ) + + private val cache = ConcurrentHashMap() + + fun getPublicKey(jwksUri: String, kid: String, ttlSeconds: Long): RSAPublicKey { + val entry = cache[jwksUri] + val now = Instant.now() + + if (entry != null && entry.expiresAt.isAfter(now)) { + entry.keys[kid]?.let { return it } + } + + return refreshAndGetKey(jwksUri, kid, ttlSeconds) + } + + private fun refreshAndGetKey(jwksUri: String, kid: String, ttlSeconds: Long): RSAPublicKey { + val keys = oidcClient.fetchPublicKeys(jwksUri) + val expiresAt = Instant.now().plusSeconds(ttlSeconds) + cache[jwksUri] = CacheEntry(keys, expiresAt) + + return keys[kid] + ?: throw BadRequestException(ErrorCode.INVALID_ID_TOKEN); + } +} diff --git a/src/main/kotlin/com/moa/common/oidc/OidcUserInfo.kt b/src/main/kotlin/com/moa/common/oidc/OidcUserInfo.kt new file mode 100644 index 0000000..8457a7d --- /dev/null +++ b/src/main/kotlin/com/moa/common/oidc/OidcUserInfo.kt @@ -0,0 +1,8 @@ +package com.moa.common.oidc + +import com.moa.entity.ProviderType + +data class OidcUserInfo( + val subject: String, + val provider: ProviderType, +) diff --git a/src/main/kotlin/com/moa/controller/AuthController.kt b/src/main/kotlin/com/moa/controller/AuthController.kt new file mode 100644 index 0000000..9ba0f0e --- /dev/null +++ b/src/main/kotlin/com/moa/controller/AuthController.kt @@ -0,0 +1,21 @@ +package com.moa.controller + +import com.moa.common.response.ApiResponse +import com.moa.service.AuthService +import com.moa.service.dto.KaKaoSignInUpRequest +import com.moa.service.dto.KakaoSignInUpResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +@RestController +class AuthController( + private val authService: AuthService, +) { + + @PostMapping("/api/v1/auth/kakao") + fun kakaoSignInUp(@RequestBody kaKaoSignInUpRequest: KaKaoSignInUpRequest): ResponseEntity> { + return ResponseEntity.ok(ApiResponse.success(authService.kakaoSignInUp(kaKaoSignInUpRequest))) + } +} diff --git a/src/main/kotlin/com/moa/controller/OnboardingController.kt b/src/main/kotlin/com/moa/controller/OnboardingController.kt index e6d4568..e715c6b 100644 --- a/src/main/kotlin/com/moa/controller/OnboardingController.kt +++ b/src/main/kotlin/com/moa/controller/OnboardingController.kt @@ -1,11 +1,9 @@ package com.moa.controller +import com.moa.common.auth.AuthenticatedMember +import com.moa.common.auth.AuthenticatedMemberInfo import com.moa.common.response.ApiResponse -import com.moa.service.OnboardingStatusService -import com.moa.service.PayrollService -import com.moa.service.ProfileService -import com.moa.service.TermsService -import com.moa.service.WorkPolicyService +import com.moa.service.* import com.moa.service.dto.PayrollUpsertRequest import com.moa.service.dto.ProfileUpsertRequest import com.moa.service.dto.TermsAgreementRequest @@ -14,7 +12,7 @@ import jakarta.validation.Valid import org.springframework.web.bind.annotation.* @RestController -@RequestMapping("/v1/onboarding") +@RequestMapping("/api/v1/onboarding") class OnboardingController( private val onboardingStatusService: OnboardingStatusService, private val profileService: ProfileService, @@ -24,30 +22,38 @@ class OnboardingController( ) { @GetMapping("/status") - fun status() = - ApiResponse.success(onboardingStatusService.getStatus()) + fun status(@AuthenticatedMember member: AuthenticatedMemberInfo) = + ApiResponse.success(onboardingStatusService.getStatus(member.id)) @PatchMapping("/profile") - fun upsertProfile(@RequestBody @Valid req: ProfileUpsertRequest) = - ApiResponse.success(profileService.upsertProfile(req)) + fun upsertProfile( + @AuthenticatedMember member: AuthenticatedMemberInfo, + @RequestBody @Valid req: ProfileUpsertRequest, + ) = ApiResponse.success(profileService.upsertProfile(member.id, req)) @PatchMapping("/payroll") - fun upsertPayroll(@RequestBody @Valid req: PayrollUpsertRequest) = - ApiResponse.success(payrollService.upsert(req)) + fun upsertPayroll( + @AuthenticatedMember member: AuthenticatedMemberInfo, + @RequestBody @Valid req: PayrollUpsertRequest, + ) = ApiResponse.success(payrollService.upsert(member.id, req)) @PatchMapping("/work-policy") - fun upsertWorkPolicy(@RequestBody @Valid req: WorkPolicyUpsertRequest) = - ApiResponse.success(workPolicyService.upsert(req)) + fun upsertWorkPolicy( + @AuthenticatedMember member: AuthenticatedMemberInfo, + @RequestBody @Valid req: WorkPolicyUpsertRequest, + ) = ApiResponse.success(workPolicyService.upsert(member.id, req)) @GetMapping("/terms") fun terms() = ApiResponse.success(termsService.getTerms()) @GetMapping("/terms/agreements") - fun agreements() = - ApiResponse.success(termsService.getAgreements()) + fun agreements(@AuthenticatedMember member: AuthenticatedMemberInfo) = + ApiResponse.success(termsService.getAgreements(member.id)) @PutMapping("/terms/agreements") - fun agree(@RequestBody @Valid req: TermsAgreementRequest) = - ApiResponse.success(termsService.upsertAgreements(req)) + fun agree( + @AuthenticatedMember member: AuthenticatedMemberInfo, + @RequestBody @Valid req: TermsAgreementRequest, + ) = ApiResponse.success(termsService.upsertAgreements(member.id, req)) } diff --git a/src/main/kotlin/com/moa/entity/Member.kt b/src/main/kotlin/com/moa/entity/Member.kt new file mode 100644 index 0000000..c7e5a99 --- /dev/null +++ b/src/main/kotlin/com/moa/entity/Member.kt @@ -0,0 +1,20 @@ +package com.moa.entity + +import jakarta.persistence.* + +@Entity +class Member( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @Enumerated(EnumType.STRING) + val provider: ProviderType, + + val providerSubject: String, + + @OneToOne(fetch = FetchType.LAZY) + val profile: Profile?, + + ) : BaseEntity() { +} diff --git a/src/main/kotlin/com/moa/entity/Profile.kt b/src/main/kotlin/com/moa/entity/Profile.kt index 7f4d987..1d0399b 100644 --- a/src/main/kotlin/com/moa/entity/Profile.kt +++ b/src/main/kotlin/com/moa/entity/Profile.kt @@ -4,6 +4,9 @@ import jakarta.persistence.* @Entity class Profile( + @Column(nullable = false, unique = true) + val memberId: Long, + @Column(nullable = false) var nickname: String, diff --git a/src/main/kotlin/com/moa/entity/ProviderType.kt b/src/main/kotlin/com/moa/entity/ProviderType.kt new file mode 100644 index 0000000..79a11aa --- /dev/null +++ b/src/main/kotlin/com/moa/entity/ProviderType.kt @@ -0,0 +1,6 @@ +package com.moa.entity + +enum class ProviderType { + KAKAO, + APPLE, +} diff --git a/src/main/kotlin/com/moa/repository/MemberRepository.kt b/src/main/kotlin/com/moa/repository/MemberRepository.kt new file mode 100644 index 0000000..293be2b --- /dev/null +++ b/src/main/kotlin/com/moa/repository/MemberRepository.kt @@ -0,0 +1,9 @@ +package com.moa.repository + +import com.moa.entity.Member +import com.moa.entity.ProviderType +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberRepository : JpaRepository { + fun findByProviderAndProviderSubject(provider: ProviderType, providerSubject: String): Member? +} diff --git a/src/main/kotlin/com/moa/repository/ProfileRepository.kt b/src/main/kotlin/com/moa/repository/ProfileRepository.kt index 56dcf8c..22c0a69 100644 --- a/src/main/kotlin/com/moa/repository/ProfileRepository.kt +++ b/src/main/kotlin/com/moa/repository/ProfileRepository.kt @@ -3,4 +3,6 @@ package com.moa.repository import com.moa.entity.Profile import org.springframework.data.jpa.repository.JpaRepository -interface ProfileRepository : JpaRepository +interface ProfileRepository : JpaRepository { + fun findByMemberId(memberId: Long): Profile? +} diff --git a/src/main/kotlin/com/moa/service/AuthService.kt b/src/main/kotlin/com/moa/service/AuthService.kt new file mode 100644 index 0000000..d8eb64c --- /dev/null +++ b/src/main/kotlin/com/moa/service/AuthService.kt @@ -0,0 +1,51 @@ +package com.moa.service + +import com.moa.common.auth.JwtTokenProvider +import com.moa.common.oidc.OidcIdTokenValidator +import com.moa.entity.Member +import com.moa.entity.ProviderType +import com.moa.repository.MemberRepository +import com.moa.service.dto.KaKaoSignInUpRequest +import com.moa.service.dto.KakaoSignInUpResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AuthService( + private val oidcIdTokenValidator: OidcIdTokenValidator, + private val jwtTokenProvider: JwtTokenProvider, + private val memberRepository: MemberRepository, +) { + + @Transactional + fun kakaoSignInUp(request: KaKaoSignInUpRequest): KakaoSignInUpResponse { + val userInfo = oidcIdTokenValidator.validate(ProviderType.KAKAO, request.idToken) + + val member = memberRepository.findByProviderAndProviderSubject( + provider = userInfo.provider, + providerSubject = userInfo.subject, + ) + + member?.let { + return KakaoSignInUpResponse( + jwtTokenProvider.createAccessToken(member.id) + ) + } + + val registeredMember = memberRepository.save( + Member( + provider = ProviderType.KAKAO, + providerSubject = userInfo.subject, + profile = null, + ) + ) + + val registerToken = jwtTokenProvider.createAccessToken( + registeredMember.id + ) + + return KakaoSignInUpResponse( + registerToken, + ) + } +} diff --git a/src/main/kotlin/com/moa/service/OnboardingStatusService.kt b/src/main/kotlin/com/moa/service/OnboardingStatusService.kt index 479d22e..8336b88 100644 --- a/src/main/kotlin/com/moa/service/OnboardingStatusService.kt +++ b/src/main/kotlin/com/moa/service/OnboardingStatusService.kt @@ -16,13 +16,8 @@ class OnboardingStatusService( private val workPolicyDayPolicyRepository: WorkPolicyDayPolicyRepository, ) { - // TODO. 로그인/Member 연동 후 memberId 가져오도록 변경 - private fun currentMemberId(): Long = 1L - @Transactional(readOnly = true) - fun getStatus(today: LocalDate = LocalDate.now()): OnboardingStatusResponse { - val memberId = currentMemberId() - + fun getStatus(memberId: Long, today: LocalDate = LocalDate.now()): OnboardingStatusResponse { // 필수 약관 동의 여부 val requiredCodes = termRepository.findAll() .filter { it.required } @@ -34,8 +29,8 @@ class OnboardingStatusService( val hasRequiredTermsAgreed = requiredCodes.all { agreements[it] == true } - // 프로필 완료 여부 (임시 stub) - val profile = profileRepository.findAll().firstOrNull() + // 프로필 완료 여부 + val profile = profileRepository.findByMemberId(memberId) val profileCompleted = profile != null && profile.nickname.isNotBlank() && profile.workplaceName.isNotBlank() diff --git a/src/main/kotlin/com/moa/service/PayrollService.kt b/src/main/kotlin/com/moa/service/PayrollService.kt index 9535c1a..2e0664d 100644 --- a/src/main/kotlin/com/moa/service/PayrollService.kt +++ b/src/main/kotlin/com/moa/service/PayrollService.kt @@ -14,12 +14,8 @@ class PayrollService( private val payrollVersionRepository: PayrollVersionRepository, ) { - // TODO. 로그인/Member 연동 후 memberId 가져오도록 변경 - private fun currentMemberId(): Long = 1L - @Transactional - fun upsert(req: PayrollUpsertRequest): PayrollResponse { - val memberId = currentMemberId() + fun upsert(memberId: Long, req: PayrollUpsertRequest): PayrollResponse { val effectiveFrom = req.effectiveFrom val salaryInputType = req.salaryInputType diff --git a/src/main/kotlin/com/moa/service/ProfileService.kt b/src/main/kotlin/com/moa/service/ProfileService.kt index b940388..6b79ec9 100644 --- a/src/main/kotlin/com/moa/service/ProfileService.kt +++ b/src/main/kotlin/com/moa/service/ProfileService.kt @@ -14,16 +14,16 @@ class ProfileService( ) { @Transactional - fun upsertProfile(req: ProfileUpsertRequest): ProfileResponse { + fun upsertProfile(memberId: Long, req: ProfileUpsertRequest): ProfileResponse { val nickname = req.nickname val workplaceName = req.workplace.name - // TODO. Member 연동 후 member.profile = profile 방식으로 1:1 연결 예정 - val profile = profileRepository.findAll().firstOrNull()?.apply { + val profile = profileRepository.findByMemberId(memberId)?.apply { this.nickname = nickname this.workplaceName = workplaceName } ?: profileRepository.save( Profile( + memberId = memberId, nickname = nickname, workplaceName = workplaceName, ) diff --git a/src/main/kotlin/com/moa/service/TermsService.kt b/src/main/kotlin/com/moa/service/TermsService.kt index 8fd818e..89240cb 100644 --- a/src/main/kotlin/com/moa/service/TermsService.kt +++ b/src/main/kotlin/com/moa/service/TermsService.kt @@ -16,9 +16,6 @@ class TermsService( private val termAgreementRepository: TermAgreementRepository, ) { - // TODO. 로그인/Member 연동 후 memberId 가져오도록 변경 - private fun currentMemberId(): Long = 1L - @Transactional(readOnly = true) fun getTerms(): TermsResponse { val terms = termRepository.findAll() @@ -35,26 +32,41 @@ class TermsService( return TermsResponse(terms = terms) } - @Transactional(readOnly = true) - fun getAgreements(): TermsAgreementsResponse { - val memberId = currentMemberId() - + @Transactional + fun getAgreements(memberId: Long): TermsAgreementsResponse { val terms = termRepository.findAll() val requiredCodes = terms.filter { it.required }.map { it.code }.toSet() - val agreements = termAgreementRepository.findAllByMemberId(memberId) - .associate { it.termCode to it.agreed } + val existingAgreements = termAgreementRepository.findAllByMemberId(memberId) + .associateBy { it.termCode } + + // Lazy create TermAgreement for new terms + val newAgreements = terms + .filter { !existingAgreements.containsKey(it.code) } + .map { term -> + TermAgreement( + memberId = memberId, + termCode = term.code, + agreed = false, + ) + } + + if (newAgreements.isNotEmpty()) { + termAgreementRepository.saveAll(newAgreements) + } + + val allAgreements = existingAgreements + newAgreements.associateBy { it.termCode } val responseAgreements = terms .sortedWith(compareByDescending { it.required }.thenBy { it.code }) .map { term -> TermAgreementDto( code = term.code, - agreed = agreements[term.code] == true, + agreed = allAgreements[term.code]?.agreed == true, ) } - val hasRequiredTermsAgreed = requiredCodes.all { agreements[it] == true } + val hasRequiredTermsAgreed = requiredCodes.all { allAgreements[it]?.agreed == true } return TermsAgreementsResponse( agreements = responseAgreements, @@ -63,9 +75,7 @@ class TermsService( } @Transactional - fun upsertAgreements(req: TermsAgreementRequest): TermsAgreementsResponse { - val memberId = currentMemberId() - + fun upsertAgreements(memberId: Long, req: TermsAgreementRequest): TermsAgreementsResponse { val terms = termRepository.findAll() val termByCode = terms.associateBy { it.code } val requiredCodes = terms.filter { it.required }.map { it.code }.toSet() @@ -100,6 +110,6 @@ class TermsService( } } - return getAgreements() + return getAgreements(memberId) } } diff --git a/src/main/kotlin/com/moa/service/WorkPolicyService.kt b/src/main/kotlin/com/moa/service/WorkPolicyService.kt index 753a752..e662a60 100644 --- a/src/main/kotlin/com/moa/service/WorkPolicyService.kt +++ b/src/main/kotlin/com/moa/service/WorkPolicyService.kt @@ -19,12 +19,8 @@ class WorkPolicyService( private val dayPolicyRepository: WorkPolicyDayPolicyRepository, ) { - // TODO. 로그인/Member 연동 후 memberId 가져오도록 변경 - private fun currentMemberId(): Long = 1L - @Transactional - fun upsert(req: WorkPolicyUpsertRequest): WorkPolicyResponse { - val memberId = currentMemberId() + fun upsert(memberId: Long, req: WorkPolicyUpsertRequest): WorkPolicyResponse { validateDays(req.days) diff --git a/src/main/kotlin/com/moa/service/dto/KaKaoSignInUpRequest.kt b/src/main/kotlin/com/moa/service/dto/KaKaoSignInUpRequest.kt new file mode 100644 index 0000000..bace07a --- /dev/null +++ b/src/main/kotlin/com/moa/service/dto/KaKaoSignInUpRequest.kt @@ -0,0 +1,7 @@ +package com.moa.service.dto + +data class KaKaoSignInUpRequest( + val idToken: String, + val fcmDeviceToken: String? = null, +) { +} diff --git a/src/main/kotlin/com/moa/service/dto/KakaoSignInUpResponse.kt b/src/main/kotlin/com/moa/service/dto/KakaoSignInUpResponse.kt new file mode 100644 index 0000000..34cef7f --- /dev/null +++ b/src/main/kotlin/com/moa/service/dto/KakaoSignInUpResponse.kt @@ -0,0 +1,5 @@ +package com.moa.service.dto + +data class KakaoSignInUpResponse( + val accessToken: String? = null, +) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..34093da --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,32 @@ +spring: + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + show_sql: true + defer-datasource-initialization: true + h2: + console: + enabled: true + path: /h2-console + + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:core;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + + sql: + init: + mode: always + data-locations: classpath:data-local.sql + +jwt: + secret-key: ${secret.jwt.local.access.secret-key} + expiration-milliseconds: ${secret.jwt.local.access.expiration} + +oidc: + kakao: + jwks-uri: ${secret.oauth.kakao.jwks-uri} + cache-ttl-seconds: ${secret.oauth.cache-ttl-seconds} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..d7dae4e --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,24 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${secret.db.prod.host}:${secret.db.prod.port}/${secret.db.prod.name}?serverTimezone=UTC&useUniCode=yes&characterEncoding=UTF-8 + username: ${secret.db.prod.username} + password: ${secret.db.prod.password} + + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + show_sql: true + +jwt: + secret-key: ${secret.jwt.prod.access.secret-key} + expiration-milliseconds: ${secret.jwt.prod.access.expiration} + +oidc: + kakao: + jwks-uri: ${secret.oauth.kakao.jwks-uri} + cache-ttl-seconds: ${secret.oauth.cache-ttl-seconds} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml deleted file mode 100644 index c226a2c..0000000 --- a/src/main/resources/application.yaml +++ /dev/null @@ -1,3 +0,0 @@ -spring: - application: - name: demo diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..e9388a1 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,9 @@ +spring: + config: + import: + - moa-secret/oauth.yml + - moa-secret/jwt.yml + - moa-secret/db.yml + + profiles: + default: local diff --git a/src/main/resources/moa-secret b/src/main/resources/moa-secret new file mode 160000 index 0000000..990182b --- /dev/null +++ b/src/main/resources/moa-secret @@ -0,0 +1 @@ +Subproject commit 990182b8bf35a88c417a3d7ee62c874c7e9b6a1d diff --git a/src/test/kotlin/com/moa/ApplicationTests.kt b/src/test/kotlin/com/moa/ApplicationTests.kt deleted file mode 100644 index 869f8c9..0000000 --- a/src/test/kotlin/com/moa/ApplicationTests.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.moa - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class ApplicationTests { - - @Test - fun contextLoads() { - } - -} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..2428436 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,12 @@ +spring: + application: + name: demo + +jwt: + secret-key: ${secret.jwt.local.access.secret-key} + expiration-milliseconds: ${secret.jwt.local.access.expiration} + +oidc: + kakao: + jwks-uri: ${secret.oauth.kakao.jwks-uri} + cache-ttl-seconds: ${secret.oauth.cache-ttl-seconds}