diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts deleted file mode 100644 index a6e17cc..0000000 --- a/build-logic/build.gradle.kts +++ /dev/null @@ -1,11 +0,0 @@ -plugins { - `kotlin-dsl` - id("casper.documentation-convention") -} - -group = "io.casper.build" -version = "1.0.0" - -repositories { - mavenCentral() -} \ No newline at end of file diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts deleted file mode 100644 index 2af0ef2..0000000 --- a/build-logic/settings.gradle.kts +++ /dev/null @@ -1,17 +0,0 @@ -rootProject.name = "build-logic" - -pluginManagement { - repositories { - gradlePluginPortal() - mavenCentral() - } - - // convention 모듈 참조 - includeBuild("../casper-convention") -} - -dependencyResolutionManagement { - repositories { - mavenCentral() - } -} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/io/casper/build/TestClass.kt b/build-logic/src/main/kotlin/io/casper/build/TestClass.kt deleted file mode 100644 index 6a5992d..0000000 --- a/build-logic/src/main/kotlin/io/casper/build/TestClass.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.casper.build - -/** - * 이 클래스는 KDoc 주석 검사 테스트를 위한 용도입니다. - * 이제 올바른 KDoc 주석 형식을 사용합니다. - */ -class TestClass { - - /** - * 이 함수는 빌드 로직에서 사용하는 테스트 함수입니다. - */ - fun testFunction() { - println("이 함수는 문서화 검사를 테스트하기 위한 용도입니다.") - } -} \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..876c922 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt new file mode 100644 index 0000000..f96af89 --- /dev/null +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -0,0 +1,51 @@ +object Dependencies { + // Spring Boot + const val SPRING_BOOT_STARTER = "org.springframework.boot:spring-boot-starter" + const val SPRING_BOOT_STARTER_WEB = "org.springframework.boot:spring-boot-starter-web" + const val SPRING_BOOT_STARTER_DATA_JPA = "org.springframework.boot:spring-boot-starter-data-jpa" + const val SPRING_BOOT_STARTER_DATA_REDIS = "org.springframework.boot:spring-boot-starter-data-redis" + const val SPRING_BOOT_STARTER_SECURITY = "org.springframework.boot:spring-boot-starter-security" + const val SPRING_BOOT_STARTER_VALIDATION = "org.springframework.boot:spring-boot-starter-validation" + const val SPRING_BOOT_STARTER_TEST = "org.springframework.boot:spring-boot-starter-test" + + // Kotlin + const val KOTLIN_REFLECT = "org.jetbrains.kotlin:kotlin-reflect" + const val KOTLIN_TEST_JUNIT5 = "org.jetbrains.kotlin:kotlin-test-junit5" + + // Database + const val MYSQL_CONNECTOR = "com.mysql:mysql-connector-j" + + // JSON + const val JACKSON_MODULE_KOTLIN = "com.fasterxml.jackson.module:jackson-module-kotlin" + const val ORG_JSON = "org.json:json:${DependencyVersion.ORG_JSON}" + + // JWT + const val JWT_API = "io.jsonwebtoken:jjwt-api:${DependencyVersion.JWT}" + const val JWT_IMPL = "io.jsonwebtoken:jjwt-impl:${DependencyVersion.JWT}" + const val JWT_JACKSON = "io.jsonwebtoken:jjwt-jackson:${DependencyVersion.JWT}" + + // MapStruct + const val MAPSTRUCT = "org.mapstruct:mapstruct:${DependencyVersion.MAPSTRUCT}" + const val MAPSTRUCT_PROCESSOR = "org.mapstruct:mapstruct-processor:${DependencyVersion.MAPSTRUCT}" + + // Test + const val JUNIT_PLATFORM_LAUNCHER = "org.junit.platform:junit-platform-launcher" + + // gRPC + const val GRPC_NETTY_SHADED = "io.grpc:grpc-netty-shaded:${DependencyVersion.GRPC}" + const val GRPC_PROTOBUF = "io.grpc:grpc-protobuf:${DependencyVersion.GRPC}" + const val GRPC_STUB = "io.grpc:grpc-stub:${DependencyVersion.GRPC}" + const val GRPC_KOTLIN_STUB = "io.grpc:grpc-kotlin-stub:${DependencyVersion.GRPC_KOTLIN}" + const val PROTOBUF_KOTLIN = "com.google.protobuf:protobuf-kotlin:${DependencyVersion.PROTOBUF}" + const val GRPC_TESTING = "io.grpc:grpc-testing:${DependencyVersion.GRPC}" + const val GRPC_SERVER_SPRING_BOOT_STARTER = "net.devh:grpc-server-spring-boot-starter:${DependencyVersion.GRPC_SPRING_BOOT_STARTER}" + + //OkCert + const val OKCERT_PATH = "src/main/webapp/WEB-INF/lib/OkCert3-java1.5-2.3.1.jar" + + //swagger + const val SWAGGER = "org.springdoc:springdoc-openapi-starter-webmvc-ui:${DependencyVersion.SWAGGER}" + + // Sentry + const val SENTRY_SPRING_BOOT_STARTER = "io.sentry:sentry-spring-boot-starter-jakarta:${DependencyVersion.SENTRY}" +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/DependencyVersion.kt b/buildSrc/src/main/kotlin/DependencyVersion.kt new file mode 100644 index 0000000..e721bbc --- /dev/null +++ b/buildSrc/src/main/kotlin/DependencyVersion.kt @@ -0,0 +1,20 @@ +object DependencyVersion { + const val KOTLIN = "1.9.25" + const val SPRING_BOOT = "3.4.4" + const val SPRING_DEPENDENCY_MANAGEMENT = "1.1.7" + const val DETEKT = "1.23.6" + const val KTLINT = "12.1.1" + + const val JWT = "0.11.5" + const val ORG_JSON = "20230227" + const val MAPSTRUCT = "1.6.0" + + const val GRPC = "1.61.1" + const val GRPC_KOTLIN = "1.4.1" + const val PROTOBUF = "3.25.3" + const val GRPC_SPRING_BOOT_STARTER = "3.1.0.RELEASE" + + const val SWAGGER = "2.7.0" + + const val SENTRY = "7.14.0" +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Plugin.kt b/buildSrc/src/main/kotlin/Plugin.kt new file mode 100644 index 0000000..84e3329 --- /dev/null +++ b/buildSrc/src/main/kotlin/Plugin.kt @@ -0,0 +1,11 @@ +object Plugin { + const val KOTLIN_JVM = "org.jetbrains.kotlin.jvm" + const val KOTLIN_SPRING = "org.jetbrains.kotlin.plugin.spring" + const val KOTLIN_KAPT = "org.jetbrains.kotlin.kapt" + const val SPRING_BOOT = "org.springframework.boot" + const val SPRING_DEPENDENCY_MANAGEMENT = "io.spring.dependency-management" + const val DETEKT = "io.gitlab.arturbosch.detekt" + const val KTLINT = "org.jlleitschuh.gradle.ktlint" + const val CASPER_DOCUMENTATION = "casper.documentation-convention" + const val PROTOBUF = "com.google.protobuf" +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/PluginVersion.kt b/buildSrc/src/main/kotlin/PluginVersion.kt new file mode 100644 index 0000000..9b16506 --- /dev/null +++ b/buildSrc/src/main/kotlin/PluginVersion.kt @@ -0,0 +1,8 @@ +object PluginVersion { + const val KOTLIN_VERSION = "1.9.25" + const val SPRING_BOOT_VERSION = "3.4.4" + const val SPRING_DEPENDENCY_MANAGEMENT_VERSION = "1.1.7" + const val DETEKT_VERSION = "1.23.6" + const val KTLINT_VERSION = "12.1.1" + const val PROTOBUF_VERSION = "0.9.4" +} \ No newline at end of file diff --git a/casper-user/build.gradle.kts b/casper-user/build.gradle.kts new file mode 100644 index 0000000..fff5b3d --- /dev/null +++ b/casper-user/build.gradle.kts @@ -0,0 +1,113 @@ +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id(Plugin.KOTLIN_JVM) version PluginVersion.KOTLIN_VERSION + id(Plugin.KOTLIN_SPRING) version PluginVersion.KOTLIN_VERSION + id(Plugin.KOTLIN_KAPT) + id(Plugin.SPRING_BOOT) version PluginVersion.SPRING_BOOT_VERSION + id(Plugin.SPRING_DEPENDENCY_MANAGEMENT) version PluginVersion.SPRING_DEPENDENCY_MANAGEMENT_VERSION + id(Plugin.CASPER_DOCUMENTATION) + id(Plugin.PROTOBUF) version PluginVersion.PROTOBUF_VERSION +} + +dependencies { + // 스프링 부트 기본 기능 + implementation(Dependencies.SPRING_BOOT_STARTER) + + // 코틀린 리플렉션 + implementation(Dependencies.KOTLIN_REFLECT) + + // 스프링 부트 테스트 도구 + testImplementation(Dependencies.SPRING_BOOT_STARTER_TEST) + + // 코틀린 + JUnit5 테스트 + testImplementation(Dependencies.KOTLIN_TEST_JUNIT5) + + // JUnit5 실행 런처 + testRuntimeOnly(Dependencies.JUNIT_PLATFORM_LAUNCHER) + + // 웹 관련 + implementation(Dependencies.SPRING_BOOT_STARTER_WEB) + + // 데이터베이스 + implementation(Dependencies.SPRING_BOOT_STARTER_DATA_JPA) + implementation(Dependencies.SPRING_BOOT_STARTER_DATA_REDIS) + runtimeOnly(Dependencies.MYSQL_CONNECTOR) + + // 보안 + implementation(Dependencies.SPRING_BOOT_STARTER_SECURITY) + + // 검증 + implementation(Dependencies.SPRING_BOOT_STARTER_VALIDATION) + + // JSON 처리 + implementation(Dependencies.JACKSON_MODULE_KOTLIN) + implementation(Dependencies.ORG_JSON) + + // JWT + implementation(Dependencies.JWT_API) + implementation(Dependencies.JWT_IMPL) + runtimeOnly(Dependencies.JWT_JACKSON) + + implementation(Dependencies.MAPSTRUCT) + kapt(Dependencies.MAPSTRUCT_PROCESSOR) + + // grpc + implementation(Dependencies.GRPC_NETTY_SHADED) + implementation(Dependencies.GRPC_PROTOBUF) + implementation(Dependencies.GRPC_STUB) + implementation(Dependencies.GRPC_KOTLIN_STUB) + implementation(Dependencies.PROTOBUF_KOTLIN) + implementation(Dependencies.GRPC_SERVER_SPRING_BOOT_STARTER) + testImplementation(Dependencies.GRPC_TESTING) + + // OkCert + implementation(files("$projectDir/${Dependencies.OKCERT_PATH}")) + + // swagger + implementation(Dependencies.SWAGGER) + + // Sentry + implementation(Dependencies.SENTRY_SPRING_BOOT_STARTER) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${DependencyVersion.PROTOBUF}" + } + plugins { + create("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:${DependencyVersion.GRPC}" + } + create("grpckt") { + artifact = "io.grpc:protoc-gen-grpc-kotlin:${DependencyVersion.GRPC_KOTLIN}:jdk8@jar" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + create("grpc") + create("grpckt") + } + } + } +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(17) +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs += "-Xjsr305=strict" + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/src/main/kotlin/hs/kr/entrydsm/user/CasperUserApplication.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/CasperUserApplication.kt similarity index 100% rename from src/main/kotlin/hs/kr/entrydsm/user/CasperUserApplication.kt rename to casper-user/src/main/kotlin/hs/kr/entrydsm/user/CasperUserApplication.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/adapter/in/web/dto/request/AdminLoginRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/adapter/in/web/dto/request/AdminLoginRequest.kt new file mode 100644 index 0000000..3c567bd --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/adapter/in/web/dto/request/AdminLoginRequest.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.user.domain.admin.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank + +/** + * 관리자 로그인 요청 데이터를 담는 DTO 클래스입니다. + */ +data class AdminLoginRequest( + @NotBlank + val adminId: String, + @NotBlank + val password: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/application/service/AdminLoginService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/application/service/AdminLoginService.kt new file mode 100644 index 0000000..803d737 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/application/service/AdminLoginService.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.user.domain.admin.application.service + +import hs.kr.entrydsm.user.domain.admin.adapter.`in`.web.dto.request.AdminLoginRequest +import hs.kr.entrydsm.user.domain.admin.application.port.`in`.AdminLoginUseCase +import hs.kr.entrydsm.user.domain.admin.application.port.out.QueryAdminPort +import hs.kr.entrydsm.user.domain.admin.application.port.out.SaveAdminPort +import hs.kr.entrydsm.user.domain.admin.exception.AdminNotFoundException +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserInfo +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserRole +import hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository.UserInfoRepository +import hs.kr.entrydsm.user.domain.user.exception.PasswordNotValidException +import hs.kr.entrydsm.user.global.security.jwt.JwtProperties +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 관리자 로그인 비즈니스 로직을 처리하는 서비스 클래스입니다. + */ +@Service +class AdminLoginService( + private val passwordEncoder: PasswordEncoder, + private val jwtTokenProvider: JwtTokenProvider, + private val userInfoRepository: UserInfoRepository, + private val jwtProperties: JwtProperties, + private val adminQueryAdminPort: QueryAdminPort, + private val adminSaveAdminPort: SaveAdminPort, +) : AdminLoginUseCase { + /** + * 관리자 로그인을 처리하고 JWT 토큰을 반환합니다. + */ + @Transactional + override fun login(adminLoginRequest: AdminLoginRequest): TokenResponse { + val admin = adminQueryAdminPort.findByAdminId(adminLoginRequest.adminId) ?: throw AdminNotFoundException + + if (!passwordEncoder.matches(adminLoginRequest.password, admin.password)) { + throw PasswordNotValidException + } + val tokenResponse = jwtTokenProvider.generateToken(admin.id.toString(), UserRole.ADMIN.toString()) + val userInfo = + UserInfo( + token = tokenResponse.accessToken, + userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), + userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), + ttl = jwtProperties.accessExp, + ) + userInfoRepository.save(userInfo) + return tokenResponse + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/Admin.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/Admin.kt new file mode 100644 index 0000000..d2968df --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/Admin.kt @@ -0,0 +1,15 @@ +package hs.kr.entrydsm.user.domain.admin.domain + +import hs.kr.entrydsm.user.global.entity.BaseUUIDEntity +import java.util.UUID +import jakarta.persistence.Column +import jakarta.persistence.Entity + +@Entity(name = "tbl_admin") +class Admin( + id: UUID?, + @Column(name = "admin_id", length = 15, nullable = false) + val adminId: String, + @Column(name = "password", length = 60, nullable = false) + val password: String, +) : BaseUUIDEntity(id) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/repository/AdminRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/repository/AdminRepository.kt new file mode 100644 index 0000000..21fe133 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/repository/AdminRepository.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.user.domain.admin.domain.repository + +import hs.kr.entrydsm.user.domain.admin.domain.Admin +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface AdminRepository : JpaRepository { + fun findByAdminId(adminId: String): Admin? +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt new file mode 100644 index 0000000..439cd13 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.user.domain.admin.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * 관리자를 찾을 수 없을 때 발생하는 예외입니다. + */ +object AdminNotFoundException : EquusException( + ErrorCode.ADMIN_NOT_FOUND, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt new file mode 100644 index 0000000..fdc0401 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.user.domain.admin.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * 관리자 권한이 없을 때 발생하는 예외입니다. + */ +object AdminUnauthorizedException : EquusException( + ErrorCode.ADMIN_UNAUTHORIZED, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt new file mode 100644 index 0000000..ede6dc0 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt @@ -0,0 +1,39 @@ +package hs.kr.entrydsm.user.domain.admin.facade + +import hs.kr.entrydsm.user.domain.admin.application.port.`in`.AdminFacadeUseCase +import hs.kr.entrydsm.user.domain.admin.application.port.out.QueryAdminPort +import hs.kr.entrydsm.user.domain.admin.exception.AdminNotFoundException +import hs.kr.entrydsm.user.domain.admin.model.Admin +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * 관리자 관련 공통 기능을 제공하는 Facade 클래스입니다. + */ +@Component +class AdminFacade( + private val queryAdminPort: QueryAdminPort, +) : AdminFacadeUseCase { + /** + * 현재 인증된 관리자의 사용자 정보를 조회합니다. + * + * @return 현재 인증된 관리자 정보 + * @throws AdminNotFoundException 관리자가 존재하지 않는 경우 + */ + override fun getCurrentUser(): Admin { + val adminId = SecurityContextHolder.getContext().authentication.name + return queryAdminPort.findById(UUID.fromString(adminId)) ?: throw AdminNotFoundException + } + + /** + * 관리자 ID로 사용자 정보를 조회합니다. + * + * @param adminId 조회할 관리자의 UUID 문자열 + * @return 조회된 관리자 정보 + * @throws AdminNotFoundException 관리자가 존재하지 않는 경우 + */ + override fun getUserById(adminId: String): Admin { + return queryAdminPort.findById(UUID.fromString(adminId)) ?: throw AdminNotFoundException + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/AdminController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/AdminController.kt new file mode 100644 index 0000000..00ddebe --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/AdminController.kt @@ -0,0 +1,47 @@ +package hs.kr.entrydsm.user.domain.admin.presentation + +import hs.kr.entrydsm.user.domain.admin.presentation.dto.request.AdminLoginRequest +import hs.kr.entrydsm.user.domain.admin.service.AdminLoginService +import hs.kr.entrydsm.user.domain.admin.service.AdminTokenRefreshService +import hs.kr.entrydsm.user.domain.admin.service.DeleteAllTableService +import hs.kr.entrydsm.user.domain.admin.service.QueryAdminByUUIDService +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID +import jakarta.validation.Valid + +@RestController +@RequestMapping("/admin") +class AdminController( + private val adminLoginService: AdminLoginService, + private val adminTokenRefreshService: AdminTokenRefreshService, + private val deleteAllTableService: DeleteAllTableService, + private val queryAdminByUUIDService: QueryAdminByUUIDService, +) { + @PostMapping("/auth") + fun login( + @RequestBody @Valid + adminLoginRequest: AdminLoginRequest, + ): TokenResponse = adminLoginService.execute(adminLoginRequest) + + @PutMapping("/auth") + fun tokenRefresh( + @RequestHeader("X-Refresh-Token") refreshToken: String, + ): TokenResponse = adminTokenRefreshService.execute(refreshToken) + + @DeleteMapping("/auth") + fun deleteAllTable() = deleteAllTableService.execute() + + @GetMapping("/{adminId}") + fun findAdminById( + @PathVariable adminId: UUID, + ) = queryAdminByUUIDService.execute(adminId) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/request/AdminLoginRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/request/AdminLoginRequest.kt new file mode 100644 index 0000000..ab6645a --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/request/AdminLoginRequest.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.user.domain.admin.presentation.dto.request + +import jakarta.validation.constraints.NotBlank + +data class AdminLoginRequest( + @NotBlank + val adminId: String, + @NotBlank + val password: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/response/InternalAdminResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/response/InternalAdminResponse.kt new file mode 100644 index 0000000..6e38dfc --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/response/InternalAdminResponse.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.user.domain.admin.presentation.dto.response + +import java.util.UUID + +data class InternalAdminResponse( + val id: UUID, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminLoginService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminLoginService.kt new file mode 100644 index 0000000..0e9abaf --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminLoginService.kt @@ -0,0 +1,43 @@ +package hs.kr.entrydsm.user.domain.admin.service + +import hs.kr.entrydsm.user.domain.admin.domain.repository.AdminRepository +import hs.kr.entrydsm.user.domain.admin.exception.AdminNotFoundException +import hs.kr.entrydsm.user.domain.admin.presentation.dto.request.AdminLoginRequest +import hs.kr.entrydsm.user.domain.user.domain.UserInfo +import hs.kr.entrydsm.user.domain.user.domain.UserRole +import hs.kr.entrydsm.user.domain.user.domain.repository.UserInfoRepository +import hs.kr.entrydsm.user.domain.user.exception.PasswordNotValidException +import hs.kr.entrydsm.user.global.security.jwt.JwtProperties +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AdminLoginService( + private val adminRepository: AdminRepository, + private val passwordEncoder: PasswordEncoder, + private val jwtTokenProvider: JwtTokenProvider, + private val userInfoRepository: UserInfoRepository, + private val jwtProperties: JwtProperties, +) { + @Transactional + fun execute(adminLoginRequest: AdminLoginRequest): TokenResponse { + val admin = adminRepository.findByAdminId(adminLoginRequest.adminId) ?: throw AdminNotFoundException + + if (!passwordEncoder.matches(adminLoginRequest.password, admin.password)) { + throw PasswordNotValidException + } + val tokenResponse = jwtTokenProvider.generateToken(admin.id.toString(), UserRole.ADMIN.toString()) + val userInfo = + UserInfo( + token = tokenResponse.accessToken, + userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), + userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), + ttl = jwtProperties.accessExp, + ) + userInfoRepository.save(userInfo) + return tokenResponse + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminTokenRefreshService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminTokenRefreshService.kt new file mode 100644 index 0000000..a49d04c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminTokenRefreshService.kt @@ -0,0 +1,14 @@ +package hs.kr.entrydsm.user.domain.admin.service + +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AdminTokenRefreshService( + private val jwtTokenProvider: JwtTokenProvider, +) { + @Transactional + fun execute(refreshToken: String): TokenResponse = jwtTokenProvider.reIssue(refreshToken) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/DeleteAllTableService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/DeleteAllTableService.kt new file mode 100644 index 0000000..d17adf3 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/DeleteAllTableService.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.user.domain.admin.service + +import hs.kr.entrydsm.user.infrastructure.kafka.producer.DeleteAllTableProducer +import org.springframework.stereotype.Service + +@Service +class DeleteAllTableService( + private val deleteAllTableProducer: DeleteAllTableProducer, +) { + fun execute() = deleteAllTableProducer.send() +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/QueryAdminByUUIDService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/QueryAdminByUUIDService.kt new file mode 100644 index 0000000..67b323c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/QueryAdminByUUIDService.kt @@ -0,0 +1,22 @@ +package hs.kr.entrydsm.user.domain.admin.service + +import hs.kr.entrydsm.user.domain.admin.domain.repository.AdminRepository +import hs.kr.entrydsm.user.domain.admin.exception.AdminNotFoundException +import hs.kr.entrydsm.user.domain.admin.presentation.dto.response.InternalAdminResponse +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class QueryAdminByUUIDService( + private val adminRepository: AdminRepository, +) { + @Transactional(readOnly = true) + fun execute(adminId: UUID): InternalAdminResponse { + val admin = adminRepository.findByIdOrNull(adminId) ?: throw AdminNotFoundException + return InternalAdminResponse( + id = admin.id!!, + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt new file mode 100644 index 0000000..8dafd3b --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt @@ -0,0 +1,60 @@ +package hs.kr.entrydsm.user.domain.auth.application.service + +import hs.kr.entrydsm.user.domain.auth.adapter.`in`.web.dto.resopnse.QueryPassInfoResponse +import hs.kr.entrydsm.user.domain.auth.adapter.out.PassInfo +import hs.kr.entrydsm.user.domain.auth.adapter.out.repository.PassInfoRepository +import hs.kr.entrydsm.user.domain.auth.application.port.`in`.QueryPassInfoUseCase +import hs.kr.entrydsm.user.domain.auth.exception.InvalidPassException +import hs.kr.entrydsm.user.global.utils.encryption.EncryptionUtil +import hs.kr.entrydsm.user.global.utils.pass.PassUtil +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 패스 인증 정보 조회 비즈니스 로직을 처리하는 서비스 클래스입니다. + */ +@Service +class QueryPassInfoService( + private val passInfoRepository: PassInfoRepository, + private val passUtil: PassUtil, + private val encryptionUtil: EncryptionUtil, +) : QueryPassInfoUseCase { + companion object { + private val RESULT_CODE = "RSLT_CD" + + private val RESULT_NAME = "RSLT_NAME" + + private val TEL_NO = "TEL_NO" + + private val RESULT_CODE_OK = "B000" + } + + @Value("\${pass.exp}") + private var exp: Long = 0L + + /** + * 토큰을 검증하고 패스 인증 정보를 조회합니다. + */ + @Transactional + override fun queryPassInfo(token: String?): QueryPassInfoResponse { + val resJson = passUtil.getResponseJson(token) + val resultCode = resJson!!.getString(RESULT_CODE) + if (RESULT_CODE_OK != resultCode) { + throw InvalidPassException + } + val name = resJson.getString(RESULT_NAME) + val phoneNumber = resJson.getString(TEL_NO) + + val passInfo = + PassInfo( + HashUtil.sha256(phoneNumber), + encryptionUtil.encrypt(phoneNumber), + encryptionUtil.encrypt(name), + exp + ) + + passInfoRepository.save(passInfo) + return QueryPassInfoResponse(phoneNumber, name) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/PassInfo.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/PassInfo.kt new file mode 100644 index 0000000..0318c78 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/PassInfo.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.auth.domain + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import org.springframework.data.redis.core.index.Indexed + +@RedisHash +class PassInfo( + @Id + val name: String, + @Indexed + val phoneNumber: String, + @TimeToLive + val ttl: Long, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/repository/PassInfoRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/repository/PassInfoRepository.kt new file mode 100644 index 0000000..3da504f --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/repository/PassInfoRepository.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.user.domain.auth.domain.repository + +import hs.kr.entrydsm.user.domain.auth.domain.PassInfo +import org.springframework.data.repository.CrudRepository +import java.util.Optional + +interface PassInfoRepository : CrudRepository { + fun findByPhoneNumber(phoneNumber: String): Optional + + fun existsByPhoneNumber(phoneNumber: String): Boolean +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt new file mode 100644 index 0000000..e3beb24 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.user.domain.auth.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * OkCert 연결이 유효하지 않을 때 발생하는 예외입니다. + */ +object InvalidOkCertConnectException : EquusException( + ErrorCode.INVALID_OKCERT_CONNECTION, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt new file mode 100644 index 0000000..630d571 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.user.domain.auth.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * Pass 인증이 유효하지 않을 때 발생하는 예외입니다. + * Pass 인증 과정에서 검증에 실패한 경우 사용됩니다. + */ +object InvalidPassException : EquusException( + ErrorCode.INVALID_PASS, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt new file mode 100644 index 0000000..1bdd34d --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.user.domain.auth.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * URL이 유효하지 않을 때 발생하는 예외입니다. + */ +object InvalidUrlException : EquusException( + ErrorCode.INVALID_URL, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt new file mode 100644 index 0000000..b962709 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.user.domain.auth.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * Pass 인증 정보를 찾을 수 없을 때 발생하는 예외입니다. + * Pass 인증을 통해 저장된 사용자 정보가 만료되었거나 존재하지 않는 경우 사용됩니다. + */ +object PassInfoNotFoundException : EquusException( + ErrorCode.PASS_INFO_NOT_FOUND, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/PassInfoController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/PassInfoController.kt new file mode 100644 index 0000000..4822fc8 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/PassInfoController.kt @@ -0,0 +1,30 @@ +package hs.kr.entrydsm.user.domain.auth.presentation + +import hs.kr.entrydsm.user.domain.auth.presentation.dto.request.PassPopupRequest +import hs.kr.entrydsm.user.domain.auth.presentation.dto.resopnse.QueryPassInfoResponse +import hs.kr.entrydsm.user.domain.auth.service.PassPopupService +import hs.kr.entrydsm.user.domain.auth.service.QueryPassInfoService +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 +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import jakarta.validation.Valid + +@RestController +@RequestMapping("/user/verify") +class PassInfoController( + private val passPopupService: PassPopupService, + private val queryPassInfoService: QueryPassInfoService, +) { + @GetMapping("/info") + fun getPassInfo( + @RequestParam("mdl_tkn") token: String, + ): QueryPassInfoResponse = queryPassInfoService.execute(token) + + @PostMapping("/popup") + fun popupPass( + @RequestBody request: @Valid PassPopupRequest, + ): String = passPopupService.execute(request) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/resopnse/QueryPassInfoResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/resopnse/QueryPassInfoResponse.kt new file mode 100644 index 0000000..9bb1087 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/resopnse/QueryPassInfoResponse.kt @@ -0,0 +1,6 @@ +package hs.kr.entrydsm.user.domain.auth.presentation.dto.resopnse + +data class QueryPassInfoResponse( + val phoneNumber: String, + val name: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/QueryPassInfoService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/QueryPassInfoService.kt new file mode 100644 index 0000000..fae2b0f --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/QueryPassInfoService.kt @@ -0,0 +1,43 @@ +package hs.kr.entrydsm.user.domain.auth.service + +import hs.kr.entrydsm.user.domain.auth.domain.PassInfo +import hs.kr.entrydsm.user.domain.auth.domain.repository.PassInfoRepository +import hs.kr.entrydsm.user.domain.auth.exception.InvalidPassException +import hs.kr.entrydsm.user.domain.auth.presentation.dto.resopnse.QueryPassInfoResponse +import hs.kr.entrydsm.user.global.utils.pass.PassUtil +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class QueryPassInfoService( + private val passInfoRepository: PassInfoRepository, + private val passUtil: PassUtil, +) { + companion object { + private val RESULT_CODE = "RSLT_CD" + + private val RESULT_NAME = "RSLT_NAME" + + private val TEL_NO = "TEL_NO" + + private val RESULT_CODE_OK = "B000" + } + + @Value("\${pass.exp}") + private var exp: Long = 0L + + @Transactional + fun execute(token: String?): QueryPassInfoResponse { + val resJson = passUtil.getResponseJson(token) + val resultCode = resJson!!.getString(RESULT_CODE) + if (RESULT_CODE_OK != resultCode) { + throw InvalidPassException + } + val name = resJson.getString(RESULT_NAME) + val phoneNumber = resJson.getString(TEL_NO) + val passInfo: PassInfo = PassInfo(name, phoneNumber, exp) + passInfoRepository.save(passInfo) + return QueryPassInfoResponse(phoneNumber, name) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/RefreshToken.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/RefreshToken.kt new file mode 100644 index 0000000..b622083 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/RefreshToken.kt @@ -0,0 +1,21 @@ +package hs.kr.entrydsm.user.domain.refreshtoken.domain + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import org.springframework.data.redis.core.index.Indexed + +@RedisHash +class RefreshToken( + @Id + val id: String, + @Indexed + var token: String, + @TimeToLive + var ttl: Long, +) { + fun update(token: String?, ttl: Long,) { + this.token = token!! + this.ttl = ttl + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/repository/RefreshTokenRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/repository/RefreshTokenRepository.kt new file mode 100644 index 0000000..b2c7bc6 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/repository/RefreshTokenRepository.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.refreshtoken.domain.repository + +import hs.kr.entrydsm.user.domain.refreshtoken.domain.RefreshToken +import org.springframework.data.repository.CrudRepository + +interface RefreshTokenRepository : CrudRepository { + fun findByToken(token: String): RefreshToken? +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/SampleController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/SampleController.kt new file mode 100644 index 0000000..1e4122e --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/SampleController.kt @@ -0,0 +1,29 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * 샘플 API를 제공하는 컨트롤러 클래스입니다. + */ +@RequestMapping("/api/v1/samples") +@RestController +class SampleController { + /** + * 샘플 ID로 샘플 정보를 조회합니다. + */ + @GetMapping("{sampleId}") + fun getSampleById( + @PathVariable sampleId: String, + ): SampleResponse = SampleResponse(sampleId, "sample-$sampleId") +} + +/** + * 샘플 응답 데이터를 담는 클래스입니다. + */ +data class SampleResponse( + val sampleId: String, + val name: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserWebAdapter.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserWebAdapter.kt new file mode 100644 index 0000000..a324a9e --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserWebAdapter.kt @@ -0,0 +1,152 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ChangePasswordRequest +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ReactivateRequest +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserLoginRequest +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserSignupRequest +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.WithdrawalRequest +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.response.UserResponse +import hs.kr.entrydsm.user.domain.user.application.port.`in`.ChangePasswordUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.QueryUserByUUIDUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.QueryUserInfoUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserLoginUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserReactivationUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserSignupUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserTokenRefreshUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserWithdrawalUseCase +import hs.kr.entrydsm.user.global.document.user.UserApiDocument +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import hs.kr.entrydsm.user.infrastructure.grpc.server.dto.InternalUserResponse +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +/** + * 사용자 관련 REST API 컨트롤러 클래스입니다. + * 사용자 회원가입, 로그인, 정보 조회, 탈퇴 등의 HTTP 요청을 처리합니다. + * + * @property userSignupUseCase 사용자 회원가입 유스케이스 + * @property userLoginUseCase 사용자 로그인 유스케이스 + * @property changePasswordUseCase 비밀번호 변경 유스케이스 + * @property userTokenRefreshUseCase 토큰 갱신 유스케이스 + * @property userWithdrawalUseCase 사용자 탈퇴 유스케이스 + * @property queryUserByUUIDUseCase UUID로 사용자 조회 유스케이스 + * @property queryUserInfoUseCase 사용자 정보 조회 유스케이스 + * @property userReactivationUseCase 사용자 재활성화 유스케이스 + */ +@RequestMapping("/user") +@RestController +class UserWebAdapter( + private val userSignupUseCase: UserSignupUseCase, + private val userLoginUseCase: UserLoginUseCase, + private val changePasswordUseCase: ChangePasswordUseCase, + private val userTokenRefreshUseCase: UserTokenRefreshUseCase, + private val userWithdrawalUseCase: UserWithdrawalUseCase, + private val queryUserByUUIDUseCase: QueryUserByUUIDUseCase, + private val queryUserInfoUseCase: QueryUserInfoUseCase, + private val userReactivationUseCase: UserReactivationUseCase, +) : UserApiDocument { + /** + * 사용자 회원가입을 처리합니다. + * Pass 인증을 통해 검증된 사용자의 회원가입을 진행합니다. + * + * @param userSignupRequest 회원가입 요청 정보 + * @return 생성된 JWT 토큰 응답 + */ + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + override fun signup( + @RequestBody @Valid + userSignupRequest: UserSignupRequest, + ): TokenResponse = userSignupUseCase.signup(userSignupRequest) + + /** + * 사용자 로그인을 처리합니다. + * 전화번호와 비밀번호를 검증하여 JWT 토큰을 발급합니다. + * + * @param userLoginRequest 로그인 요청 정보 + * @return 생성된 JWT 토큰 응답 + */ + @PostMapping("/auth") + override fun login( + @RequestBody @Valid + userLoginRequest: UserLoginRequest, + ): TokenResponse = userLoginUseCase.login(userLoginRequest) + + /** + * 탈퇴한 계정을 재활성화합니다. + * + * @param request 재활성화 요청 정보 + */ + @PostMapping("/reactivate") + fun reactivateAccount( + @RequestBody @Valid request: ReactivateRequest, + ) { + userReactivationUseCase.reactivateWithdrawnUser(request) + } + + /** + * 사용자 비밀번호를 변경합니다. + * Pass 인증을 통한 본인 확인 후 새로운 비밀번호로 변경합니다. + * + * @param changePasswordRequest 비밀번호 변경 요청 정보 + */ + @PatchMapping("/password") + override fun changePassword( + @RequestBody @Valid + changePasswordRequest: ChangePasswordRequest, + ) = changePasswordUseCase.changePassword(changePasswordRequest) + + /** + * 만료된 액세스 토큰을 갱신합니다. + * + * @param refreshToken 리프레시 토큰 + * @return 새로 발급된 JWT 토큰 응답 + */ + @PutMapping("/auth") + override fun tokenRefresh( + @RequestHeader("X-Refresh-Token") refreshToken: String, + ): TokenResponse = userTokenRefreshUseCase.refresh(refreshToken) + + /** + * 사용자 탈퇴를 처리합니다. + * 비밀번호 확인 후 계정을 비활성화합니다. + * + * @param request 탈퇴 요청 정보 + */ + @DeleteMapping + override fun withdrawal( + @RequestBody @Valid request: WithdrawalRequest, + ) = userWithdrawalUseCase.withdrawal(request) + + /** + * UUID로 사용자 정보를 조회합니다. + * 내부 시스템 간 통신에서 사용되는 API입니다. + * + * @param userId 조회할 사용자 UUID + * @return 내부 시스템용 사용자 정보 응답 + */ + @GetMapping("/{userId}") + override fun findUserByUUID( + @PathVariable userId: UUID, + ): InternalUserResponse = queryUserByUUIDUseCase.getUserById(userId) + + /** + * 현재 로그인한 사용자의 정보를 조회합니다. + * + * @return 사용자 정보 응답 + */ + @GetMapping("/info") + override fun getUserInfo(): UserResponse = queryUserInfoUseCase.getUserInfo() +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ChangePasswordRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ChangePasswordRequest.kt new file mode 100644 index 0000000..f42e28a --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ChangePasswordRequest.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern + +/** + * 사용자 비밀번호 변경 요청 데이터를 담는 DTO 클래스입니다. + */ +data class ChangePasswordRequest( + @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + val phoneNumber: String, + @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + @Pattern( + regexp = + "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", + message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", + ) + val newPassword: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ReactivateRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ReactivateRequest.kt new file mode 100644 index 0000000..3a3dc5a --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ReactivateRequest.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank + +data class ReactivateRequest( + @NotBlank + val phoneNumber: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserLoginRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserLoginRequest.kt new file mode 100644 index 0000000..8d7acec --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserLoginRequest.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern + +/** + * 사용자 로그인 요청 데이터를 담는 DTO 클래스입니다. + */ +data class UserLoginRequest( + @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + val phoneNumber: String, + @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + @Pattern( + regexp = + "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", + message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", + ) + val password: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserSignupRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserSignupRequest.kt new file mode 100644 index 0000000..a386f12 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserSignupRequest.kt @@ -0,0 +1,22 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern + +/** + * 사용자 회원가입 요청 데이터를 담는 DTO 클래스입니다. + */ +data class UserSignupRequest( + @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + val phoneNumber: String, + @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + @Pattern( + regexp = + "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", + message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", + ) + val password: String, + @NotNull + val isParent: Boolean, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/WIthdrawalRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/WIthdrawalRequest.kt new file mode 100644 index 0000000..9fa7036 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/WIthdrawalRequest.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank + +data class WithdrawalRequest( + @NotBlank + val password: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/response/UserResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/response/UserResponse.kt new file mode 100644 index 0000000..90b3666 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/response/UserResponse.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.response + +/** + * 사용자 정보 응답 데이터를 담는 DTO 클래스입니다. + */ +data class UserResponse( + val name: String, + val phoneNumber: String, + val isParent: Boolean, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/UserJpaEntity.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/UserJpaEntity.kt new file mode 100644 index 0000000..29cd743 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/UserJpaEntity.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserRole +import hs.kr.entrydsm.user.global.converter.EncryptedStringConverter +import hs.kr.entrydsm.user.global.base.BaseUUIDEntity +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import java.time.LocalDateTime +import java.util.UUID + +/** + * 사용자 정보를 데이터베이스에 저장하기 위한 JPA 엔티티 클래스입니다. + * 데이터베이스의 tbl_user 테이블과 매핑되며, 민감한 정보는 암호화하여 저장합니다. + * + * @property phoneNumber 암호화된 전화번호 + * @property phoneNumberHash 조회를 위한 해시화된 전화번호 + * @property password 해시화된 비밀번호 + * @property name 암호화된 사용자 이름 + * @property isParent 학부모 여부 + * @property receiptCode 지원서 접수번호 + * @property role 사용자 역할 + * @property isActive 계정 활성화 상태 + * @property withdrawalAt 탈퇴 일시 + */ +@Entity(name = "tbl_user") +class UserJpaEntity( + id: UUID?, + @Column(columnDefinition = "char(11)", nullable = false, unique = true) + @Convert(converter = EncryptedStringConverter::class) + val phoneNumber: String, + @Column(name = "phone_number_hash", columnDefinition = "varchar(64)", nullable = false, unique = true) + val phoneNumberHash: String, + @Column(columnDefinition = "char(60)", nullable = false) + var password: String, + @Column(columnDefinition = "char(5)", nullable = false) + @Convert(converter = EncryptedStringConverter::class) + val name: String, + @Column(columnDefinition = "bit(1) default 1", nullable = false) + val isParent: Boolean, + @Column(name = "receipt_code", nullable = true) + var receiptCode: Long?, + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + val role: UserRole, + @Column(name = "is_active", columnDefinition = "bit(1) default 1", nullable = false) + var isActive: Boolean = true, + @Column(name = "withdrawal_at", nullable = true) + var withdrawalAt: LocalDateTime? = null, +) : BaseUUIDEntity(id) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserCache.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserCache.kt new file mode 100644 index 0000000..51fe62c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserCache.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.domain + +import hs.kr.entrydsm.user.domain.user.model.User +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import java.util.UUID + +/** + * Redis에 저장되는 사용자 캐시 데이터를 나타내는 클래스입니다. + * 사용자 조회 성능 향상을 위한 캐시 데이터를 관리합니다. + * + * @property id 사용자 고유 식별자 (Redis 키로 사용) + * @property phoneNumber 전화번호 + * @property name 사용자 이름 + * @property isParent 학부모 여부 + * @property receiptCode 지원서 접수번호 + * @property role 사용자 역할 + * @property ttl Time To Live (캐시 만료 시간, 초 단위) + */ +@RedisHash(value = "user_cache") +data class UserCache( + @Id + val id: UUID?, + val phoneNumber: String, + val name: String, + val isParent: Boolean, + val receiptCode: Long?, + val role: UserRole, + @TimeToLive + val ttl: Long, +) { + companion object { + /** + * User 도메인 모델로부터 UserCache 인스턴스를 생성합니다. + * + * @param user 도메인 모델 User 인스턴스 + * @return UserCache 인스턴스 (TTL 10분으로 설정) + */ + fun from(user: User): UserCache { + return UserCache( + id = user.id, + phoneNumber = user.phoneNumber, + name = user.name, + isParent = user.isParent, + receiptCode = user.receiptCode, + role = user.role, + ttl = 60 * 10, + ) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserInfo.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserInfo.kt new file mode 100644 index 0000000..784429e --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserInfo.kt @@ -0,0 +1,27 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.domain + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import org.springframework.data.redis.core.index.Indexed + +/** + * Redis에 저장되는 사용자 인증 정보를 나타내는 클래스입니다. + * JWT 토큰과 관련된 사용자 정보를 캐시하여 인증 성능을 향상시킵니다. + * + * @property token JWT 토큰 (Redis 키로 사용) + * @property userId 사용자 식별자 + * @property userRole 사용자 역할 + * @property ttl Time To Live (토큰 만료 시간, 초 단위) + */ +@RedisHash +class UserInfo( + @Id + val token: String, + @Indexed + val userId: String, + @Indexed + val userRole: String, + @TimeToLive + val ttl: Long, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserRole.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserRole.kt new file mode 100644 index 0000000..83f60ab --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserRole.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.domain + +/** + * 사용자 권한을 정의하는 열거형 클래스입니다. + * 시스템 내에서 사용자의 접근 권한을 구분하는 데 사용됩니다. + */ +enum class UserRole { + /** 최고 관리자 권한 */ + ROOT, + + /** 일반 사용자 권한 */ + USER, + + /** 관리자 권한 */ + ADMIN, +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt new file mode 100644 index 0000000..b32272f --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt @@ -0,0 +1,22 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.mapper + +import hs.kr.entrydsm.user.domain.user.adapter.out.UserJpaEntity +import hs.kr.entrydsm.user.domain.user.model.User +import hs.kr.entrydsm.user.global.mapper.GenericMapper +import org.mapstruct.Mapper +import org.mapstruct.Mapping + +/** + * User 도메인 모델과 UserJpaEntity 간의 변환을 담당하는 매퍼 클래스입니다. + * MapStruct를 사용하여 도메인 계층과 인프라스트럭처 계층 간의 데이터 변환을 처리합니다. + */ +@Mapper(componentModel = "spring") +abstract class UserMapper : GenericMapper { + + @Mapping(target = "changePassword", ignore = true) + @Mapping(target = "changeReceiptCode", ignore = true) + abstract override fun toEntity(model: User): UserJpaEntity + + abstract override fun toModel(entity: UserJpaEntity?): User? + abstract override fun toModelNotNull(entity: UserJpaEntity): User +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/UserPersistenceAdapter.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/UserPersistenceAdapter.kt new file mode 100644 index 0000000..c3f4f06 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/UserPersistenceAdapter.kt @@ -0,0 +1,95 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.persistence + +import hs.kr.entrydsm.user.domain.user.adapter.out.mapper.UserMapper +import hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository.UserRepository +import hs.kr.entrydsm.user.domain.user.application.port.out.UserPort +import hs.kr.entrydsm.user.domain.user.model.User +import hs.kr.entrydsm.user.global.utils.encryption.EncryptionUtil +import hs.kr.entrydsm.user.global.utils.encryption.HashUtil +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.util.UUID + +/** + * 사용자 데이터의 영속성 처리를 담당하는 어댑터 클래스입니다. + * 헥사고날 아키텍처의 Driven Adapter 역할을 하며, 도메인 계층의 UserPort를 구현합니다. + * + * @property userRepository JPA 기반 사용자 데이터 저장소 + * @property userMapper 도메인 모델과 JPA 엔티티 간 변환 매퍼 + * @property encryptionUtil 민감한 데이터 암호화 유틸리티 + */ +@Component +class UserPersistenceAdapter( + private val userRepository: UserRepository, + private val userMapper: UserMapper, + private val encryptionUtil: EncryptionUtil, +) : UserPort { + /** + * ID로 사용자를 조회합니다. + * + * @param id 조회할 사용자 ID + * @return 조회된 사용자 도메인 모델 (없으면 null) + */ + override fun findById(id: UUID): User? { + return userRepository.findByIdOrNull(id) + ?.let { userMapper.toModel(it) } + } + + /** + * 전화번호로 사용자를 조회합니다. + * 전화번호를 암호화하여 데이터베이스에서 조회합니다. + * + * @param phoneNumber 조회할 전화번호 + * @return 조회된 사용자 도메인 모델 (없으면 null) + */ + override fun findByPhoneNumber(phoneNumber: String): User? { + val phoneNumberHash = HashUtil.sha256(phoneNumber) + return userRepository.findByPhoneNumberHash(phoneNumberHash) + ?.let { userMapper.toModel(it) } + } + + /** + * 전화번호로 사용자 존재 여부를 확인합니다. + * + * @param phoneNumber 확인할 전화번호 + * @return 사용자 존재 여부 + */ + override fun existsByPhoneNumber(phoneNumber: String): Boolean { + val phoneNumberHash = HashUtil.sha256(phoneNumber) + return userRepository.existsByPhoneNumberHash(phoneNumberHash) + } + + /** + * 사용자 정보를 저장합니다. + * + * @param user 저장할 사용자 도메인 모델 + * @return 저장된 사용자 도메인 모델 + */ + override fun save(user: User): User { + val entity = userMapper.toEntity(user) + val savedEntity = userRepository.save(entity) + return userMapper.toModelNotNull(savedEntity) + } + + /** + * ID로 사용자를 삭제합니다. + * + * @param userId 삭제할 사용자 ID + */ + override fun deleteById(userId: UUID) { + userRepository.deleteById(userId) + } + + /** + * 지정된 일수보다 오래된 탈퇴 사용자를 조회합니다. + * + * @param days 기준 일수 + * @return 삭제 대상 사용자 목록 + */ + override fun findWithdrawnUsersOlderThan(days: Long): List { + val cutoffDate = LocalDateTime.now().minusDays(days) + return userRepository.findAllByIsActiveFalseAndWithdrawalAtBefore(cutoffDate) + .map { userMapper.toModelNotNull(it) } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserCacheRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserCacheRepository.kt new file mode 100644 index 0000000..10f8bc8 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserCacheRepository.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserCache +import org.springframework.data.repository.CrudRepository +import java.util.UUID + +/** + * 사용자 캐시를 위한 Redis 저장소 인터페이스입니다. + * Spring Data Redis를 통해 사용자 캐시 데이터의 관리를 담당합니다. + */ +interface UserCacheRepository : CrudRepository diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserInfoRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserInfoRepository.kt new file mode 100644 index 0000000..3bdc5d4 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserInfoRepository.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserInfo +import org.springframework.data.repository.CrudRepository + +/** + * 사용자 인증 정보를 위한 Redis 저장소 인터페이스입니다. + * Spring Data Redis를 통해 사용자 인증 캐시 데이터를 관리합니다. + */ +interface UserInfoRepository : CrudRepository diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserRepository.kt new file mode 100644 index 0000000..a179363 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserRepository.kt @@ -0,0 +1,47 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository + +import hs.kr.entrydsm.user.domain.user.adapter.out.UserJpaEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime +import java.util.UUID + +/** + * 사용자 JPA 엔티티를 위한 저장소 인터페이스입니다. + * Spring Data JPA를 통해 기본 CRUD 작업과 커스텀 쿼리를 제공합니다. + */ +interface UserRepository : JpaRepository { + + + + + /** + * 전화번호로 사용자를 조회합니다. + * + * @param phoneNumberHash 조회할 전화번호 + * @return 조회된 사용자 엔티티 (없으면 null) + */ + fun findByPhoneNumberHash(phoneNumberHash: String): UserJpaEntity? + + /** + * 전화번호로 사용자 존재 여부를 확인합니다. + * + * @param phoneNumberHash 확인할 전화번호 + * @return 사용자 존재 여부 + */ + fun existsByPhoneNumberHash(phoneNumberHash: String): Boolean + + /** + * ID로 사용자를 삭제합니다. + * + * @param id 삭제할 사용자 ID + */ + fun deleteById(id: UUID?) + + /** + * 지정된 일시 이전에 탈퇴한 비활성 사용자들을 조회합니다. + * + * @param cutoffDate 기준 일시 + * @return 삭제 대상 사용자 엔티티 목록 + */ + fun findAllByIsActiveFalseAndWithdrawalAtBefore(cutoffDate: LocalDateTime): List +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangePasswordUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangePasswordUseCase.kt new file mode 100644 index 0000000..1d11a86 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangePasswordUseCase.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ChangePasswordRequest + +/** + * 사용자 비밀번호 변경 유스케이스 인터페이스입니다. + * 기존 비밀번호 확인 후 새로운 비밀번호로 변경하는 기능을 정의합니다. + */ +interface ChangePasswordUseCase { + /** + * 사용자의 비밀번호를 변경합니다. + * + * @param request 비밀번호 변경 요청 정보 + */ + fun changePassword(request: ChangePasswordRequest) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangeReceiptCodeUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangeReceiptCodeUseCase.kt new file mode 100644 index 0000000..498ffa4 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangeReceiptCodeUseCase.kt @@ -0,0 +1,20 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import java.util.UUID + +/** + * 사용자 접수코드 변경 유스케이스 인터페이스입니다. + * 사용자의 지원서 접수번호를 업데이트하는 기능을 정의합니다. + */ +interface ChangeReceiptCodeUseCase { + /** + * 사용자의 접수코드를 변경합니다. + * + * @param userId 사용자 ID + * @param receiptCode 새로운 접수코드 + */ + fun changeReceiptCode( + userId: UUID, + receiptCode: Long, + ) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserByUUIDUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserByUUIDUseCase.kt new file mode 100644 index 0000000..10c9e18 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserByUUIDUseCase.kt @@ -0,0 +1,18 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.infrastructure.grpc.server.dto.InternalUserResponse +import java.util.UUID + +/** + * UUID로 사용자 조회 유스케이스 인터페이스입니다. + * 고유 식별자를 통한 사용자 정보 조회 기능을 정의합니다. + */ +interface QueryUserByUUIDUseCase { + /** + * UUID를 이용하여 사용자 정보를 조회합니다. + * + * @param userId 조회할 사용자의 UUID + * @return 사용자 정보 응답 + */ + fun getUserById(userId: UUID): InternalUserResponse +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserInfoUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserInfoUseCase.kt new file mode 100644 index 0000000..96b0411 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserInfoUseCase.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.response.UserResponse + +/** + * 사용자 정보 조회 유스케이스 인터페이스입니다. + * 현재 인증된 사용자의 개인정보를 조회하는 기능을 정의합니다. + */ +interface QueryUserInfoUseCase { + /** + * 현재 로그인한 사용자의 정보를 조회합니다. + * + * @return 사용자 정보 응답 + */ + fun getUserInfo(): UserResponse +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserCleanUpUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserCleanUpUseCase.kt new file mode 100644 index 0000000..9fdef58 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserCleanUpUseCase.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +/** + * 사용자 데이터 정리 유스케이스 인터페이스입니다. + * 일정 기간이 지난 탈퇴 사용자 데이터를 정리하는 기능을 정의합니다. + */ +interface UserCleanUpUseCase { + /** + * 탈퇴한 지 일정 기간이 지난 사용자 데이터를 정리합니다. + * 개인정보보호법에 따른 데이터 보관 기간 준수를 위한 기능입니다. + */ + fun cleanupWithdrawnUsers() +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserFacadeUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserFacadeUseCase.kt new file mode 100644 index 0000000..1bd1d4c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserFacadeUseCase.kt @@ -0,0 +1,24 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.model.User + +/** + * 사용자 파사드 유스케이스 인터페이스입니다. + * 여러 계층에서 공통으로 사용되는 사용자 조회 기능을 정의합니다. + */ +interface UserFacadeUseCase { + /** + * 현재 인증된 사용자를 조회합니다. + * + * @return 현재 인증된 사용자 + */ + fun getCurrentUser(): User + + /** + * 전화번호로 사용자를 조회합니다. + * + * @param phoneNumber 조회할 전화번호 + * @return 조회된 사용자 + */ + fun getUserByPhoneNumber(phoneNumber: String): User +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserLoginUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserLoginUseCase.kt new file mode 100644 index 0000000..281848b --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserLoginUseCase.kt @@ -0,0 +1,18 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserLoginRequest +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse + +/** + * 사용자 로그인 유스케이스 인터페이스입니다. + * 사용자 인증 및 토큰 발급 처리를 정의합니다. + */ +interface UserLoginUseCase { + /** + * 사용자 로그인을 처리합니다. + * + * @param request 로그인 요청 정보 + * @return 생성된 인증 토큰 응답 + */ + fun login(request: UserLoginRequest): TokenResponse +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserReactivationUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserReactivationUseCase.kt new file mode 100644 index 0000000..b282ba4 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserReactivationUseCase.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ReactivateRequest + +/** + * 사용자 계정 재활성화 유스케이스 인터페이스입니다. + * 탈퇴한 사용자의 계정을 다시 활성화하는 기능을 정의합니다. + */ +interface UserReactivationUseCase { + /** + * 탈퇴한 사용자의 계정을 재활성화합니다. + * + * @param request 재활성화 요청 정보 + */ + fun reactivateWithdrawnUser(request: ReactivateRequest) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserSignupUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserSignupUseCase.kt new file mode 100644 index 0000000..25b3bdd --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserSignupUseCase.kt @@ -0,0 +1,18 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserSignupRequest +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse + +/** + * 사용자 회원가입 유스케이스 인터페이스입니다. + * 새로운 사용자의 회원가입 처리를 정의합니다. + */ +interface UserSignupUseCase { + /** + * 사용자 회원가입을 처리합니다. + * + * @param request 회원가입 요청 정보 + * @return 생성된 인증 토큰 응답 + */ + fun signup(request: UserSignupRequest): TokenResponse +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserTokenRefreshUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserTokenRefreshUseCase.kt new file mode 100644 index 0000000..c790277 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserTokenRefreshUseCase.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse + +/** + * 사용자 토큰 갱신 유스케이스 인터페이스입니다. + * 만료된 액세스 토큰을 리프레시 토큰으로 갱신하는 기능을 정의합니다. + */ +interface UserTokenRefreshUseCase { + /** + * 리프레시 토큰을 이용하여 새로운 액세스 토큰을 발급합니다. + * + * @param refreshToken 기존 리프레시 토큰 + * @return 새로 발급된 토큰 응답 + */ + fun refresh(refreshToken: String): TokenResponse +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserWithdrawalUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserWithdrawalUseCase.kt new file mode 100644 index 0000000..a678a92 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserWithdrawalUseCase.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.WithdrawalRequest + +/** + * 사용자 탈퇴 유스케이스 인터페이스입니다. + * 사용자 계정 탈퇴 처리를 정의합니다. + */ +interface UserWithdrawalUseCase { + /** + * 사용자 탈퇴를 처리합니다. + * + * @param request 탈퇴 요청 정보 + */ + fun withdrawal(request: WithdrawalRequest) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/DeleteUserPort.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/DeleteUserPort.kt new file mode 100644 index 0000000..15cf42c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/DeleteUserPort.kt @@ -0,0 +1,25 @@ +package hs.kr.entrydsm.user.domain.user.application.port.out + +import hs.kr.entrydsm.user.domain.user.model.User +import java.util.UUID + +/** + * 사용자 삭제 작업을 위한 포트 인터페이스입니다. + * 헥사고날 아키텍처에서 도메인 계층이 인프라스트럭처 계층과 통신하기 위한 인터페이스입니다. + */ +interface DeleteUserPort { + /** + * ID로 사용자를 삭제합니다. + * + * @param userId 삭제할 사용자 ID + */ + fun deleteById(userId: UUID) + + /** + * 지정된 일수보다 오래된 탈퇴 사용자를 조회합니다. + * + * @param days 기준 일수 + * @return 삭제 대상 사용자 목록 + */ + fun findWithdrawnUsersOlderThan(days: Long): List +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/QueryUserPort.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/QueryUserPort.kt new file mode 100644 index 0000000..04bfc35 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/QueryUserPort.kt @@ -0,0 +1,34 @@ +package hs.kr.entrydsm.user.domain.user.application.port.out + +import hs.kr.entrydsm.user.domain.user.model.User +import java.util.UUID + +/** + * 사용자 조회 작업을 위한 포트 인터페이스입니다. + * 헥사고날 아키텍처에서 도메인 계층이 인프라스트럭처 계층과 통신하기 위한 인터페이스입니다. + */ +interface QueryUserPort { + /** + * ID로 사용자를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자 도메인 모델 (없으면 null) + */ + fun findById(userId: UUID): User? + + /** + * 전화번호로 사용자를 조회합니다. + * + * @param phoneNumber 조회할 전화번호 + * @return 조회된 사용자 도메인 모델 (없으면 null) + */ + fun findByPhoneNumber(phoneNumber: String): User? + + /** + * 전화번호로 사용자 존재 여부를 확인합니다. + * + * @param phoneNumber 확인할 전화번호 + * @return 사용자 존재 여부 + */ + fun existsByPhoneNumber(phoneNumber: String): Boolean +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/SaveUserPort.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/SaveUserPort.kt new file mode 100644 index 0000000..d9aa002 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/SaveUserPort.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.user.domain.user.application.port.out + +import hs.kr.entrydsm.user.domain.user.model.User + +/** + * 사용자 저장 작업을 위한 포트 인터페이스입니다. + * 헥사고날 아키텍처에서 도메인 계층이 인프라스트럭처 계층과 통신하기 위한 인터페이스입니다. + */ +interface SaveUserPort { + /** + * 사용자 정보를 저장합니다. + * + * @param user 저장할 사용자 도메인 모델 + * @return 저장된 사용자 도메인 모델 + */ + fun save(user: User): User +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/UserPort.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/UserPort.kt new file mode 100644 index 0000000..4167aec --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/UserPort.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.user.domain.user.application.port.out + +/** + * 사용자 관련 모든 포트 인터페이스를 통합한 포트입니다. + * 사용자 데이터의 CRUD 작업을 위한 모든 인터페이스를 상속받습니다. + */ +interface UserPort : DeleteUserPort, SaveUserPort, QueryUserPort diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt new file mode 100644 index 0000000..e6c9198 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt @@ -0,0 +1,53 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.auth.adapter.out.repository.PassInfoRepository +import hs.kr.entrydsm.user.domain.auth.exception.PassInfoNotFoundException +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ChangePasswordRequest +import hs.kr.entrydsm.user.domain.user.application.port.`in`.ChangePasswordUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.application.port.out.SaveUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 비밀번호 변경 서비스 클래스입니다. + * Pass 인증을 통한 본인 확인 후 비밀번호 변경을 처리합니다. + * + * @property queryUserPort 사용자 조회 포트 + * @property saveUserPort 사용자 저장 포트 + * @property passwordEncoder 비밀번호 암호화 인코더 + * @property passInfoRepository Pass 정보 저장소 + */ +@Service +class ChangePasswordService( + private val queryUserPort: QueryUserPort, + private val saveUserPort: SaveUserPort, + private val passwordEncoder: PasswordEncoder, + private val passInfoRepository: PassInfoRepository, +) : ChangePasswordUseCase { + /** + * 사용자의 비밀번호를 변경합니다. + * Pass 인증 확인 후 새로운 비밀번호로 암호화하여 저장합니다. + * + * @param request 비밀번호 변경 요청 정보 + * @throws PassInfoNotFoundException Pass 인증 정보가 없는 경우 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + */ + @Transactional + override fun changePassword(request: ChangePasswordRequest) { + val phoneNumberHash = HashUtil.sha256(request.phoneNumber) + + if (!passInfoRepository.existsById(phoneNumberHash)) { + throw PassInfoNotFoundException + } + + val user = + queryUserPort.findByPhoneNumber(request.phoneNumber) + ?: throw UserNotFoundException + + val updatedUser = user.changePassword(passwordEncoder.encode(request.newPassword)) + saveUserPort.save(updatedUser) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangeReceiptCodeService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangeReceiptCodeService.kt new file mode 100644 index 0000000..26e17b2 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangeReceiptCodeService.kt @@ -0,0 +1,39 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.application.port.`in`.ChangeReceiptCodeUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.application.port.out.SaveUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * 사용자 접수코드 변경 서비스 클래스입니다. + * 지원서 접수 시 사용자에게 할당된 접수번호를 업데이트하는 처리를 담당합니다. + * + * @property queryUserPort 사용자 조회 포트 + * @property saveUserPort 사용자 저장 포트 + */ +@Transactional +@Service +class ChangeReceiptCodeService( + private val queryUserPort: QueryUserPort, + private val saveUserPort: SaveUserPort, +) : ChangeReceiptCodeUseCase { + /** + * 사용자의 접수코드를 변경합니다. + * + * @param userId 사용자 ID + * @param receiptCode 새로운 접수코드 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + */ + override fun changeReceiptCode( + userId: UUID, + receiptCode: Long, + ) { + val user = queryUserPort.findById(userId) ?: throw UserNotFoundException + val updateUser = user.changeReceiptCode(receiptCode) + saveUserPort.save(updateUser) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserByUUIDService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserByUUIDService.kt new file mode 100644 index 0000000..9363fba --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserByUUIDService.kt @@ -0,0 +1,50 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserCache +import hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository.UserCacheRepository +import hs.kr.entrydsm.user.domain.user.application.port.`in`.QueryUserByUUIDUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import hs.kr.entrydsm.user.infrastructure.grpc.server.dto.InternalUserResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * UUID로 사용자 조회 서비스 클래스입니다. + * 고유 식별자를 통한 사용자 정보 조회 및 캐시 관리를 처리합니다. + * + * @property queryUserPort 사용자 조회 포트 + * @property userCacheRepository 사용자 캐시 저장소 + */ +@Service +class QueryUserByUUIDService( + private val queryUserPort: QueryUserPort, + private val userCacheRepository: UserCacheRepository, +) : QueryUserByUUIDUseCase { + /** + * UUID를 이용하여 사용자 정보를 조회합니다. + * 캐시가 없는 경우 새로 생성하여 저장하고, 내부 시스템용 응답 형태로 변환합니다. + * + * @param userId 조회할 사용자의 UUID + * @return 내부 시스템용 사용자 정보 응답 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + */ + @Transactional(readOnly = true) + override fun getUserById(userId: UUID): InternalUserResponse { + val user = queryUserPort.findById(userId) ?: throw UserNotFoundException + + if (!userCacheRepository.existsById(userId)) { + val userCache = UserCache.from(user) + userCacheRepository.save(userCache) + } + return InternalUserResponse( + id = user.id!!, + phoneNumber = user.phoneNumber, + name = user.name, + isParent = user.isParent, + receiptCode = user.receiptCode, + role = user.role, + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserInfoService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserInfoService.kt new file mode 100644 index 0000000..246f351 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserInfoService.kt @@ -0,0 +1,37 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.response.UserResponse +import hs.kr.entrydsm.user.domain.user.application.port.`in`.QueryUserInfoUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserFacadeUseCase +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 정보 조회 서비스 클래스입니다. + * 현재 인증된 사용자의 개인정보를 조회하여 응답 형태로 변환하는 처리를 담당합니다. + * + * @property userFacadeUseCase 사용자 파사드 유스케이스 + */ +@Service +@Transactional(readOnly = true) +class QueryUserInfoService( + private val userFacadeUseCase: UserFacadeUseCase, +) : QueryUserInfoUseCase { + /** + * 현재 로그인한 사용자의 정보를 조회합니다. + * Spring Security 컨텍스트에서 인증된 사용자 정보를 가져와 응답 형태로 변환합니다. + * + * @return 사용자 정보 응답 + */ + override fun getUserInfo(): UserResponse { + val user = userFacadeUseCase.getCurrentUser() + + return user.run { + UserResponse( + name = name, + phoneNumber = phoneNumber, + isParent = isParent, + ) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserCleanUpService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserCleanUpService.kt new file mode 100644 index 0000000..e2cadeb --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserCleanUpService.kt @@ -0,0 +1,32 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserCleanUpUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.DeleteUserPort +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 데이터 정리 서비스 클래스입니다. + * 일정 기간이 지난 탈퇴 사용자 데이터를 자동으로 삭제하여 개인정보보호법을 준수합니다. + * + * @property deleteUserPort 사용자 삭제 포트 + */ +@Service +class UserCleanUpService( + private val deleteUserPort: DeleteUserPort, +) : UserCleanUpUseCase { + /** + * 탈퇴한 지 7일이 지난 사용자 데이터를 정리합니다. + * 매일 새벽 2시에 자동으로 실행되는 스케줄러입니다. + */ + @Transactional + @Scheduled(cron = "0 0 2 * * *") + override fun cleanupWithdrawnUsers() { + val usersToDelete = deleteUserPort.findWithdrawnUsersOlderThan(7) + + usersToDelete.forEach { user -> + deleteUserPort.deleteById(user.id!!) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserLoginService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserLoginService.kt new file mode 100644 index 0000000..68b7acb --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserLoginService.kt @@ -0,0 +1,71 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.refreshtoken.adapter.out.repository.RefreshTokenRepository +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserLoginRequest +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserInfo +import hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository.UserInfoRepository +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserLoginUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.exception.PasswordNotValidException +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import hs.kr.entrydsm.user.global.security.jwt.JwtProperties +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 로그인 서비스 클래스입니다. + * 사용자 인증 및 JWT 토큰 발급 처리를 담당합니다. + * + * @property jwtTokenProvider JWT 토큰 제공자 + * @property passwordEncoder 비밀번호 암호화 인코더 + * @property queryUserPort 사용자 조회 포트 + * @property jwtProperties JWT 설정 프로퍼티 + * @property userInfoRepository 사용자 정보 저장소 + */ +@Service +class UserLoginService( + private val jwtTokenProvider: JwtTokenProvider, + private val passwordEncoder: PasswordEncoder, + private val queryUserPort: QueryUserPort, + private val jwtProperties: JwtProperties, + private val userInfoRepository: UserInfoRepository, + private val refreshTokenRepository: RefreshTokenRepository, +) : UserLoginUseCase { + /** + * 사용자 로그인을 처리하고 JWT 토큰을 반환합니다. + * 전화번호와 비밀번호를 검증한 후 토큰을 발급하고 사용자 정보를 캐시합니다. + * + * @param request 로그인 요청 정보 + * @return 생성된 JWT 토큰 응답 + * @throws UserNotFoundException 사용자가 존재하지 않거나 비활성화된 경우 + * @throws PasswordNotValidException 비밀번호가 일치하지 않는 경우 + */ + @Transactional + override fun login(request: UserLoginRequest): TokenResponse { + val user = queryUserPort.findByPhoneNumber(request.phoneNumber) ?: throw UserNotFoundException + + if (!user.isActive) { + throw UserNotFoundException + } + + if (!passwordEncoder.matches(request.password, user.password)) { + throw PasswordNotValidException + } + + refreshTokenRepository.deleteById(user.id.toString()) + + val tokenResponse = jwtTokenProvider.generateToken(user.id.toString(), user.role.toString()) + val userInfo = + UserInfo( + token = tokenResponse.accessToken, + userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), + userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), + ttl = jwtProperties.accessExp, + ) + userInfoRepository.save(userInfo) + return tokenResponse + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserReactivationService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserReactivationService.kt new file mode 100644 index 0000000..5faae1b --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserReactivationService.kt @@ -0,0 +1,45 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ReactivateRequest +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserReactivationUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.application.port.out.SaveUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserAlreadyExistsException +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 계정 재활성화 서비스 클래스입니다. + * 탈퇴한 사용자의 계정을 다시 활성화하는 처리를 담당합니다. + * + * @property queryUserPort 사용자 조회 포트 + * @property saveUserPort 사용자 저장 포트 + */ +@Service +class UserReactivationService( + private val queryUserPort: QueryUserPort, + private val saveUserPort: SaveUserPort, +) : UserReactivationUseCase { + /** + * 탈퇴한 사용자의 계정을 재활성화합니다. + * 전화번호로 탈퇴한 사용자를 찾아 계정을 다시 활성화합니다. + * + * @param request 재활성화 요청 정보 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + * @throws UserAlreadyExistsException 이미 활성화된 계정인 경우 + */ + @Transactional + override fun reactivateWithdrawnUser(request: ReactivateRequest) { + val existingUser = + queryUserPort.findByPhoneNumber(request.phoneNumber) + ?: throw UserNotFoundException + + if (existingUser.isActive) { + throw UserAlreadyExistsException + } + + val reactivatedUser = existingUser.reactivate() + saveUserPort.save(reactivatedUser) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt new file mode 100644 index 0000000..ae23b09 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt @@ -0,0 +1,81 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.auth.adapter.out.repository.PassInfoRepository +import hs.kr.entrydsm.user.domain.auth.exception.PassInfoNotFoundException +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserSignupRequest +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserRole +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserSignupUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.application.port.out.SaveUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserAlreadyExistsException +import hs.kr.entrydsm.user.domain.user.model.User +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.encryption.EncryptionUtil +import hs.kr.entrydsm.user.global.utils.encryption.HashUtil +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 회원가입 서비스 클래스입니다. + * Pass 인증을 통해 검증된 사용자의 회원가입 처리를 담당합니다. + * + * @property saveUserPort 사용자 저장 포트 + * @property queryUserPort 사용자 조회 포트 + * @property passInfoRepository Pass 정보 저장소 + * @property passwordEncoder 비밀번호 암호화 인코더 + * @property tokenProvider JWT 토큰 제공자 + * @property encryptionUtil 암호화 유틸리티 + */ +@Service +class UserSignupService( + private val saveUserPort: SaveUserPort, + private val queryUserPort: QueryUserPort, + private val passInfoRepository: PassInfoRepository, + private val passwordEncoder: PasswordEncoder, + private val tokenProvider: JwtTokenProvider, + private val encryptionUtil: EncryptionUtil, +) : UserSignupUseCase { + /** + * 사용자 회원가입을 처리하고 JWT 토큰을 반환합니다. + * Pass 인증 정보를 기반으로 사용자를 생성하고 토큰을 발급합니다. + * + * @param request 회원가입 요청 정보 + * @return 생성된 JWT 토큰 응답 + * @throws UserAlreadyExistsException 이미 존재하는 사용자인 경우 + * @throws PassInfoNotFoundException Pass 인증 정보를 찾을 수 없는 경우 + */ + @Transactional + override fun signup(request: UserSignupRequest): TokenResponse { + val phoneNumber = request.phoneNumber + val phoneNumberHash = HashUtil.sha256(phoneNumber) + + if (queryUserPort.existsByPhoneNumber(phoneNumber)) { + throw UserAlreadyExistsException + } + + val passInfo = + passInfoRepository.findById(phoneNumberHash) + .orElseThrow { PassInfoNotFoundException } + + val user = + User( + id = null, + phoneNumber = encryptionUtil.decrypt(passInfo.phoneNumber), + phoneNumberHash = phoneNumberHash, + password = passwordEncoder.encode(request.password), + name = encryptionUtil.decrypt(passInfo.name), + isParent = request.isParent, + receiptCode = null, + role = UserRole.USER, + ) + + val savedUser = saveUserPort.save(user) + + return tokenProvider.generateToken( + savedUser.id.toString(), + savedUser.role.toString(), + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserTokenRefreshService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserTokenRefreshService.kt new file mode 100644 index 0000000..4909f10 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserTokenRefreshService.kt @@ -0,0 +1,48 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserInfo +import hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository.UserInfoRepository +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserTokenRefreshUseCase +import hs.kr.entrydsm.user.global.security.jwt.JwtProperties +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 토큰 갱신 서비스 클래스입니다. + * 만료된 액세스 토큰을 리프레시 토큰으로 갱신하는 처리를 담당합니다. + * + * @property jwtTokenProvider JWT 토큰 제공자 + * @property jwtProperties JWT 설정 프로퍼티 + * @property userInfoRepository 사용자 정보 저장소 + */ +@Transactional +@Service +class UserTokenRefreshService( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtProperties: JwtProperties, + private val userInfoRepository: UserInfoRepository, +) : UserTokenRefreshUseCase { + /** + * 리프레시 토큰을 이용하여 새로운 액세스 토큰을 발급합니다. + * 새로운 토큰 정보를 Redis에 저장하여 인증 상태를 유지합니다. + * + * @param refreshToken 기존 리프레시 토큰 + * @return 새로 발급된 토큰 응답 + */ + override fun refresh(refreshToken: String): TokenResponse { + val tokenResponse = jwtTokenProvider.reIssue(refreshToken) + + val userInfo = + UserInfo( + token = tokenResponse.accessToken, + userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), + userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), + ttl = jwtProperties.accessExp, + ) + + userInfoRepository.save(userInfo) + return tokenResponse + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserWithdrawalService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserWithdrawalService.kt new file mode 100644 index 0000000..67b62a3 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserWithdrawalService.kt @@ -0,0 +1,59 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.refreshtoken.adapter.out.repository.RefreshTokenRepository +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.WithdrawalRequest +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserFacadeUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserWithdrawalUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.DeleteUserPort +import hs.kr.entrydsm.user.domain.user.application.port.out.SaveUserPort +import hs.kr.entrydsm.user.domain.user.exception.PasswordMisMatchException +import hs.kr.entrydsm.user.infrastructure.kafka.producer.DeleteUserProducer +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 탈퇴 서비스 클래스입니다. + * 비밀번호 확인 후 계정 비활성화 및 관련 데이터 정리를 처리합니다. + * + * @property deleteUserProducer 사용자 삭제 이벤트 발행자 + * @property deleteUserPort 사용자 삭제 포트 + * @property saveUserPort 사용자 저장 포트 + * @property userFacadeUseCase 사용자 파사드 유스케이스 + * @property refreshTokenRepository 리프레시 토큰 저장소 + * @property passwordEncoder 비밀번호 암호화 인코더 + */ +@Service +class UserWithdrawalService( + private val deleteUserProducer: DeleteUserProducer, + private val deleteUserPort: DeleteUserPort, + private val saveUserPort: SaveUserPort, + private val userFacadeUseCase: UserFacadeUseCase, + private val refreshTokenRepository: RefreshTokenRepository, + private val passwordEncoder: PasswordEncoder, +) : UserWithdrawalUseCase { + /** + * 사용자 탈퇴를 처리합니다. + * 비밀번호 확인 후 계정을 비활성화하고 토큰을 삭제하며, 관련 서비스에 알림을 발송합니다. + * + * @param request 탈퇴 요청 정보 + * @throws PasswordMisMatchException 비밀번호가 일치하지 않는 경우 + */ + @Transactional + override fun withdrawal(request: WithdrawalRequest) { + val user = userFacadeUseCase.getCurrentUser() + + if (!passwordEncoder.matches(request.password, user.password)) { + throw PasswordMisMatchException + } + + val withdrawnUser = user.withdraw() + saveUserPort.save(withdrawnUser) + + refreshTokenRepository.deleteById(user.id.toString()) + + user.receiptCode?.let { + deleteUserProducer.send(it) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordMisMatchException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordMisMatchException.kt new file mode 100644 index 0000000..1bd0002 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordMisMatchException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.user.domain.user.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * 비밀번호가 일치하지 않을 때 발생하는 예외입니다. + * 사용자가 입력한 비밀번호가 저장된 비밀번호와 다른 경우 사용됩니다. + */ +object PasswordMisMatchException : EquusException( + ErrorCode.PASSWORD_MISS_MATCH, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt new file mode 100644 index 0000000..396ee09 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.user.domain.user.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * 패스워드가 유효하지 않을 때 발생하는 예외입니다. + * 로그인 시 입력한 비밀번호가 잘못된 경우 사용됩니다. + */ +object PasswordNotValidException : EquusException( + ErrorCode.INVALID_USER_PASSWORD, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt new file mode 100644 index 0000000..ee633bf --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.user.domain.user.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * 사용자가 이미 존재할 때 발생하는 예외입니다. + * 회원가입 시 이미 등록된 전화번호로 가입을 시도하는 경우 사용됩니다. + */ +object UserAlreadyExistsException : EquusException( + ErrorCode.USER_ALREADY_EXISTS, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt new file mode 100644 index 0000000..39c9709 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.user.domain.user.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * 사용자를 찾을 수 없을 때 발생하는 예외입니다. + * 요청한 사용자 정보가 시스템에 존재하지 않는 경우 사용됩니다. + */ +object UserNotFoundException : EquusException( + ErrorCode.USER_NOT_FOUND, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt new file mode 100644 index 0000000..73d56e0 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt @@ -0,0 +1,43 @@ +package hs.kr.entrydsm.user.domain.user.facade + +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserFacadeUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import hs.kr.entrydsm.user.domain.user.model.User +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * 사용자 관련 공통 기능을 제공하는 파사드 클래스입니다. + * 여러 계층에서 공통으로 사용되는 사용자 조회 기능을 중앙화합니다. + * + * @property queryUserPort 사용자 조회 포트 + */ +@Component +class UserFacade( + private val queryUserPort: QueryUserPort, +) : UserFacadeUseCase { + /** + * 현재 인증된 사용자를 조회합니다. + * Spring Security 컨텍스트에서 사용자 ID를 추출하여 사용자 정보를 반환합니다. + * + * @return 현재 인증된 사용자 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + */ + override fun getCurrentUser(): User { + val userId = SecurityContextHolder.getContext().authentication.name + return queryUserPort.findById(UUID.fromString(userId)) ?: throw UserNotFoundException + } + + /** + * 전화번호로 사용자를 조회합니다. + * + * @param phoneNumber 조회할 전화번호 + * @return 조회된 사용자 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + */ + override fun getUserByPhoneNumber(phoneNumber: String): User { + return queryUserPort.findByPhoneNumber(phoneNumber) ?: throw UserNotFoundException + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/model/User.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/model/User.kt new file mode 100644 index 0000000..bfd6c5c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/model/User.kt @@ -0,0 +1,80 @@ +package hs.kr.entrydsm.user.domain.user.model + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserRole +import java.time.LocalDateTime +import java.util.UUID + +/** + * 사용자 도메인 모델을 나타내는 데이터 클래스입니다. + * 시스템의 핵심 사용자 정보와 관련된 비즈니스 로직을 포함합니다. + * 불변성을 보장하는 데이터 클래스로 설계되었습니다. + * + * @property id 사용자 고유 식별자 + * @property phoneNumber 사용자 전화번호 + * @property phoneNumberHash 암호화 된 전화번호를 조회하기 위한 Hash 컬럼 + * @property password 암호화된 비밀번호 + * @property name 사용자 이름 + * @property isParent 학부모 여부 + * @property receiptCode 지원서 접수번호 + * @property role 사용자 역할 + * @property isActive 계정 활성화 상태 + * @property withdrawalAt 탈퇴 일시 + */ +data class User( + val id: UUID?, + val phoneNumber: String, + val phoneNumberHash: String, + val password: String, + val name: String, + val isParent: Boolean, + val receiptCode: Long?, + val role: UserRole, + val isActive: Boolean = true, + val withdrawalAt: LocalDateTime? = null, +) { + /** + * 사용자의 비밀번호를 변경합니다. + * + * @param newPassword 새로운 비밀번호 + * @return 비밀번호가 변경된 User 인스턴스 + */ + fun changePassword(newPassword: String): User { + return copy(password = newPassword) + } + + /** + * 사용자의 접수코드를 변경합니다. + * + * @param newReceiptCode 새로운 접수코드 + * @return 접수코드가 변경된 User 인스턴스 + */ + fun changeReceiptCode(newReceiptCode: Long): User { + return copy(receiptCode = newReceiptCode) + } + + /** + * 사용자 계정을 탈퇴 처리합니다. + * 계정을 비활성화하고 탈퇴 일시를 현재 시간으로 설정합니다. + * + * @return 탈퇴 처리된 User 인스턴스 + */ + fun withdraw(): User { + return copy( + isActive = false, + withdrawalAt = LocalDateTime.now(), + ) + } + + /** + * 탈퇴한 계정을 재활성화합니다. + * 계정을 활성화하고 탈퇴 일시를 초기화합니다. + * + * @return 재활성화된 User 인스턴스 + */ + fun reactivate(): User { + return copy( + isActive = true, + withdrawalAt = null, + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt new file mode 100644 index 0000000..cdfc75a --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt @@ -0,0 +1,43 @@ +package hs.kr.entrydsm.user.global.config + +import jakarta.annotation.PostConstruct +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration +import java.io.IOException +import java.net.URL +import java.nio.file.Files +import java.nio.file.Paths + +/** + * 패스 인증 라이센스 파일을 다운로드하고 설정하는 클래스입니다. + */ +@Configuration +class LicenseConfig( + @Value("\${pass.license_file_url}") + val licenseFileURl: String, +) { + /** + * 애플리케이션 시작 시 라이센스 파일을 다운로드합니다. + */ + @PostConstruct + fun initialize() { + try { + URL(licenseFileURl).openStream() + .use { inputStream -> + Files.copy(inputStream, Paths.get(PATH)) + } + } catch (e: IOException) { + e.printStackTrace() + } + } + + /** + * 라이센스 파일 경로 상수 + */ + companion object { + /** + * 라이센스 파일 저장 경로 + */ + private const val PATH = "./V61290000000_IDS_01_PROD_AES_license.dat" + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt new file mode 100644 index 0000000..7575acf --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt @@ -0,0 +1,59 @@ +package hs.kr.entrydsm.user.global.config + +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator +import com.fasterxml.jackson.module.kotlin.KotlinModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer + +/** + * Redis 설정 클래스입니다. + * Redis 연결 및 직렬화 설정을 담당합니다. + */ +@Configuration +class RedisConfig { + /** + * Redis Template을 설정합니다. + * 문자열 키와 JSON 직렬화를 사용하는 Redis 템플릿을 구성합니다. + * + * @param connectionFactory Redis 연결 팩토리 + * @param objectMapper JSON 직렬화를 위한 ObjectMapper + * @return 구성된 RedisTemplate + */ + @Bean + fun redisTemplate( + connectionFactory: RedisConnectionFactory, + objectMapper: ObjectMapper, + ): RedisTemplate { + val template = RedisTemplate() + template.connectionFactory = connectionFactory + template.keySerializer = StringRedisSerializer() + template.valueSerializer = Jackson2JsonRedisSerializer(objectMapper, Any::class.java) + return template + } + + /** + * JSON 직렬화를 위한 ObjectMapper를 설정합니다. + * Kotlin 모듈과 타입 정보를 포함한 JSON 직렬화를 지원합니다. + * + * @return 구성된 ObjectMapper + */ + @Bean + fun objectMapper(): ObjectMapper { + val mapper = ObjectMapper() + mapper.registerModule(KotlinModule.Builder().build()) + mapper.activateDefaultTyping( + LaissezFaireSubTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY, + ) + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt new file mode 100644 index 0000000..349544e --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt @@ -0,0 +1,21 @@ +package hs.kr.entrydsm.user.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +/** + * 정적 리소스 라우팅을 설정하는 클래스입니다. + */ +@Configuration +class StaticRoutingConfiguration : WebMvcConfigurer { + /** + * 정적 리소스 핸들러를 추가합니다. + * + * @param registry 리소스 핸들러를 등록할 레지스트리 + */ + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + registry.addResourceHandler("*/dist/**").addResourceLocations("classpath:/static/") + registry.addResourceHandler("swagger-ui.html").addResourceLocations("classpath:/static/swagger-ui/") + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt new file mode 100644 index 0000000..1781c46 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.user.global.error + +/** + * API 오류 응답을 나타내는 데이터 클래스입니다. + * 클라이언트에게 일관된 형식의 오류 정보를 제공합니다. + * + * @property status HTTP 상태 코드 + * @property message 오류 메시지 + */ +data class ErrorResponse( + val status: Int, + val message: String?, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt new file mode 100644 index 0000000..bbc70ec --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt @@ -0,0 +1,67 @@ +package hs.kr.entrydsm.user.global.error + +import com.fasterxml.jackson.databind.ObjectMapper +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode +import io.sentry.Sentry +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.web.filter.OncePerRequestFilter +import java.io.IOException +import java.nio.charset.StandardCharsets + +/** + * 전역 예외 처리 필터 클래스입니다. + * Spring Security 필터 체인에서 발생하는 예외를 처리하여 일관된 오류 응답을 제공합니다. + * + * @property objectMapper JSON 직렬화를 위한 ObjectMapper + */ +class GlobalExceptionFilter( + private val objectMapper: ObjectMapper, +) : OncePerRequestFilter() { + /** + * 필터 체인에서 발생하는 예외를 처리합니다. + * EquusException과 일반 Exception을 구분하여 처리하고, Sentry로 예외를 추적합니다. + * + * @param request HTTP 요청 + * @param response HTTP 응답 + * @param filterChain 필터 체인 + */ + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + try { + filterChain.doFilter(request, response) + } catch (e: EquusException) { + Sentry.captureException(e) + writerErrorCode(response, e.errorCode) + } catch (e: Exception) { + e.printStackTrace() + Sentry.captureException(e) + writerErrorCode(response, ErrorCode.INTERNAL_SERVER_ERROR) + } + } + + /** + * 에러 코드를 HTTP 응답으로 작성합니다. + * + * @param response HTTP 응답 객체 + * @param errorCode 발생한 에러 코드 + * @throws IOException 응답 작성 중 IO 오류 발생 시 + */ + @Throws(IOException::class) + private fun writerErrorCode( + response: HttpServletResponse, + errorCode: ErrorCode, + ) { + val errorResponse = ErrorResponse(errorCode.status, errorCode.message) + response.status = errorCode.status + response.characterEncoding = StandardCharsets.UTF_8.name() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.writer.write(objectMapper.writeValueAsString(errorResponse)) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt new file mode 100644 index 0000000..135032a --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt @@ -0,0 +1,48 @@ +package hs.kr.entrydsm.user.global.error + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import kotlin.collections.get + +/** + * 애플리케이션의 전역 예외 처리를 담당하는 클래스입니다. + * 모든 컨트롤러에서 발생하는 예외를 처리하여 일관된 오류 응답을 제공합니다. + */ +@RestControllerAdvice +class GlobalExceptionHandler { + /** + * Equus 애플리케이션의 커스텀 예외를 처리합니다. + * + * @param e EquusException 인스턴스 + * @return 에러 코드에 따른 응답 엔티티 + */ + @ExceptionHandler(EquusException::class) + fun handlingEquusException(e: EquusException): ResponseEntity { + val code = e.errorCode + return ResponseEntity( + ErrorResponse(code.status, code.message), + HttpStatus.valueOf(code.status), + ) + } + + /** + * 유효성 검증 실패 예외를 처리합니다. + * + * @param e MethodArgumentNotValidException 인스턴스 + * @return 400 에러 응답 + */ + @ExceptionHandler(MethodArgumentNotValidException::class) + fun validatorExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity { + return ResponseEntity( + ErrorResponse( + 400, + e.bindingResult.allErrors[0].defaultMessage, + ), + HttpStatus.BAD_REQUEST, + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/CasperException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/CasperException.kt new file mode 100644 index 0000000..7d06b3f --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/CasperException.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.user.global.error.exception + +import java.lang.RuntimeException + +/** + * Equus 애플리케이션의 모든 커스텀 예외의 기본 클래스입니다. + * 에러 코드를 포함하여 일관된 예외 처리를 제공합니다. + * + * @property errorCode 발생한 오류의 에러 코드 + */ +abstract class CasperException( + val errorCode: ErrorCode, +) : RuntimeException() diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt new file mode 100644 index 0000000..8b019fd --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt @@ -0,0 +1,30 @@ +package hs.kr.entrydsm.user.global.error.exception + +/** + * 애플리케이션에서 발생하는 오류 코드를 정의하는 열거형 클래스입니다. + * HTTP 상태 코드와 에러 메시지를 함께 관리합니다. + * + * @property status HTTP 상태 코드 + * @property message 에러 메시지 + */ +enum class ErrorCode( + val status: Int, + val message: String, +) { + INVALID_TOKEN(401, "Invalid Token"), + EXPIRED_TOKEN(401, "Expired Token"), + INVALID_URL(401, "Invalid Url"), + INVALID_PASS(401, "Invalid Pass"), + INVALID_USER_PASSWORD(401, "Invalid User Password"), + ADMIN_UNAUTHORIZED(401, "Admin UnAuthorized"), + PASSWORD_MISS_MATCH(401, "비밀번호가 일치하지 않습니다"), + + INTERNAL_SERVER_ERROR(500, "Internal Server Error"), + INVALID_OKCERT_CONNECTION(500, "Invalid OkCert Connection"), + + USER_NOT_FOUND(404, "User Not Found"), + PASS_INFO_NOT_FOUND(404, "Pass Info Not Found"), + ADMIN_NOT_FOUND(404, "Admin Not Found"), + + USER_ALREADY_EXISTS(409, "User Already Exists"), +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt new file mode 100644 index 0000000..182c660 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.user.global.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * 토큰이 만료되었을 때 발생하는 예외입니다. + * JWT 토큰의 유효 기간이 만료된 경우 사용됩니다. + */ +object ExpiredTokenException : EquusException( + ErrorCode.EXPIRED_TOKEN, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt new file mode 100644 index 0000000..803a1ef --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.user.global.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * 내부 서버 오류가 발생했을 때 사용하는 예외입니다. + * 예상치 못한 서버 측 오류가 발생한 경우 사용됩니다. + */ +object InternalServerErrorException : EquusException( + ErrorCode.INTERNAL_SERVER_ERROR, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt new file mode 100644 index 0000000..e6b3fdc --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.user.global.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * 토큰이 유효하지 않을 때 발생하는 예외입니다. + * JWT 토큰의 형식이 잘못되었거나 서명이 일치하지 않는 경우 사용됩니다. + */ +object InvalidTokenException : EquusException( + ErrorCode.INVALID_TOKEN, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt new file mode 100644 index 0000000..eeae3ba --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt @@ -0,0 +1,32 @@ +package hs.kr.entrydsm.user.global.security + +import com.fasterxml.jackson.databind.ObjectMapper +import hs.kr.entrydsm.user.global.error.GlobalExceptionFilter +import hs.kr.entrydsm.user.global.security.jwt.JwtFilter +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 +import org.springframework.stereotype.Component + +/** + * 보안 필터 체인을 설정하는 클래스입니다. + */ +@Component +class FilterConfig( + private val objectMapper: ObjectMapper, +) : SecurityConfigurerAdapter() { + /** + * 보안 필터를 설정합니다. + * JWT 필터와 전역 예외 필터를 Spring Security 필터 체인에 추가합니다. + * + * @param http HTTP 보안 설정 객체 + */ + override fun configure(http: HttpSecurity) { + val jwtFilter = JwtFilter() + val globalExceptionFilter = GlobalExceptionFilter(objectMapper) + + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) + .addFilterBefore(globalExceptionFilter, JwtFilter::class.java) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt new file mode 100644 index 0000000..98839a8 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt @@ -0,0 +1,66 @@ +package hs.kr.entrydsm.user.global.security + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.web.SecurityFilterChain + +/** + * Spring Security 설정 클래스입니다. + * 애플리케이션의 보안 정책과 인증/인가 규칙을 정의합니다. + * + * @property objectMapper JSON 직렬화/역직렬화를 위한 ObjectMapper + */ +@Configuration +class SecurityConfig( + private val objectMapper: ObjectMapper, +) { + /** + * Spring Security 필터 체인을 구성합니다. + * HTTP 보안 설정 및 경로별 접근 권한을 정의합니다. + * + * @param http HttpSecurity 객체 + * @return 구성된 SecurityFilterChain + */ + @Bean + protected fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .cors { it.disable() } + .formLogin { it.disable() } + .sessionManagement { + it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + .authorizeHttpRequests { + it + .requestMatchers("/").permitAll() + .requestMatchers(HttpMethod.PATCH, "/user/password").permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.POST, "/user").permitAll() + .requestMatchers("/user/verify/popup").permitAll() + .requestMatchers(HttpMethod.GET, "/user/verify/info").permitAll() + .requestMatchers(HttpMethod.POST, "/user/auth").permitAll() + .requestMatchers(HttpMethod.PUT, "/user/auth").permitAll() + .requestMatchers(HttpMethod.POST, "/admin/auth").permitAll() + .requestMatchers(HttpMethod.DELETE, "/admin/auth").hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/user").hasRole("ROOT") + .requestMatchers(HttpMethod.GET, "/admin/").hasRole("ROOT") + .anyRequest().authenticated() + } + .with(FilterConfig(objectMapper)) { } + + return http.build() + } + + /** + * 비밀번호 암호화를 위한 BCrypt 인코더를 설정합니다. + * + * @return BCryptPasswordEncoder 인스턴스 + */ + @Bean + fun passwordEncoder() = BCryptPasswordEncoder() +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt new file mode 100644 index 0000000..a969fc0 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt @@ -0,0 +1,24 @@ +package hs.kr.entrydsm.user.global.security.auth + +import hs.kr.entrydsm.user.domain.admin.application.port.`in`.AdminFacadeUseCase +import hs.kr.entrydsm.user.domain.admin.exception.AdminUnauthorizedException +import hs.kr.entrydsm.user.domain.admin.model.Admin +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service + +/** + * Spring Security 관리자 인증을 위한 사용자 상세 정보 로딩 서비스 클래스입니다. + */ +@Service +class AdminDetailsService( + private val adminFacadeUseCase: AdminFacadeUseCase, +) : UserDetailsService { + /** + * 관리자 ID로 관리자 정보를 로드합니다. + */ + override fun loadUserByUsername(adminId: String?): UserDetails { + val admin: Admin = adminId?.let { adminFacadeUseCase.getUserById(it) } ?: throw AdminUnauthorizedException + return AuthDetails(admin.adminId) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt new file mode 100644 index 0000000..bdcbef3 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.user.global.security.auth + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails + +/** + * Spring Security 인증을 위한 사용자 상세 정보를 담는 클래스입니다. + * + * @property userId 사용자 ID + */ +class AuthDetails( + private val userId: String, +) : UserDetails { + /** + * 상수 정의 + */ + companion object { + /** + * 기본 사용자 역할 + */ + private val ROLE_USER = "ROLE_USER" + } + + override fun getAuthorities(): Collection { + return listOf(SimpleGrantedAuthority(ROLE_USER)) + } + + override fun getPassword(): String? { + return null + } + + override fun getUsername(): String? { + return userId + } + + override fun isAccountNonExpired(): Boolean { + return true + } + + override fun isAccountNonLocked(): Boolean { + return true + } + + override fun isCredentialsNonExpired(): Boolean { + return true + } + + override fun isEnabled(): Boolean { + return true + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt new file mode 100644 index 0000000..d2771dc --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt @@ -0,0 +1,23 @@ +package hs.kr.entrydsm.user.global.security.auth + +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserFacadeUseCase +import hs.kr.entrydsm.user.domain.user.model.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service + +/** + * Spring Security 인증을 위한 사용자 상세 정보 로딩 서비스 클래스입니다. + */ +@Service +class AuthDetailsService( + private val userFacadeUseCase: UserFacadeUseCase, +) : UserDetailsService { + /** + * 전화번호로 사용자 정보를 로드합니다. + */ + override fun loadUserByUsername(phoneNumber: String?): UserDetails { + val user: User? = phoneNumber?.let { userFacadeUseCase.getUserByPhoneNumber(it) } + return AuthDetails(user!!.phoneNumber) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt new file mode 100644 index 0000000..1e2a912 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt @@ -0,0 +1,252 @@ +package hs.kr.entrydsm.user.global.security.jwt + +import hs.kr.entrydsm.user.domain.refreshtoken.adapter.out.RefreshToken +import hs.kr.entrydsm.user.domain.refreshtoken.adapter.out.repository.RefreshTokenRepository +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserRole +import hs.kr.entrydsm.user.global.exception.ExpiredTokenException +import hs.kr.entrydsm.user.global.exception.InvalidTokenException +import hs.kr.entrydsm.user.global.security.auth.AdminDetailsService +import hs.kr.entrydsm.user.global.security.auth.AuthDetailsService +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import io.jsonwebtoken.Claims +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jws +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.security.Keys +import jakarta.servlet.http.HttpServletRequest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.util.Date +import javax.crypto.SecretKey + +/** + * JWT 토큰의 생성, 검증, 파싱을 담당하는 클래스입니다. + * 액세스 토큰과 리프레시 토큰을 관리하며, 토큰 기반 인증을 처리합니다. + * + * @property jwtProperties JWT 관련 설정 프로퍼티 + * @property authDetailsService 사용자 상세 정보 서비스 + * @property refreshTokenRepository 리프레시 토큰 저장소 + * @property adminDetailsService 관리자 상세 정보 서비스 + */ +@Component +class JwtTokenProvider( + private val jwtProperties: JwtProperties, + private val authDetailsService: AuthDetailsService, + private val refreshTokenRepository: RefreshTokenRepository, + private val adminDetailsService: AdminDetailsService, +) { + companion object { + private const val ACCESS_KEY = "access_token" + private const val REFRESH_KEY = "refresh_token" + } + + private val secretKey: SecretKey by lazy { + Keys.hmacShaKeyFor(jwtProperties.secretKey.toByteArray(StandardCharsets.UTF_8)) + } + + /** + * 토큰에서 클레임을 추출합니다. + * + * @param token 파싱할 JWT 토큰 + * @return 토큰의 클레임 정보 + * @throws InvalidTokenException 토큰이 유효하지 않은 경우 + */ + private fun getBody(token: String): Claims { + return try { + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token).body + } catch (e: JwtException) { + throw InvalidTokenException + } + } + + /** + * 토큰에서 만료 시간을 확인하고 subject를 반환합니다. + * + * @param token 확인할 JWT 토큰 + * @return 토큰의 subject (사용자 ID) + * @throws ExpiredTokenException 토큰이 만료된 경우 + */ + fun getSubjectWithExpiredCheck(token: String): String { + val body = getBody(token) + return if (body.expiration.before(Date())) { + throw ExpiredTokenException + } else { + body.subject + } + } + + /** + * 리프레시 토큰을 이용하여 새로운 토큰을 발급합니다. + * + * @param refreshToken 기존 리프레시 토큰 + * @return 새로 발급된 토큰 응답 + * @throws InvalidTokenException 토큰이 유효하지 않은 경우 + */ + fun reIssue(refreshToken: String): TokenResponse { + if (!isRefreshToken(refreshToken)) { + throw InvalidTokenException + } + + refreshTokenRepository.findByToken(refreshToken) + ?.let { token -> + val id = token.id + val role = getRole(token.token) + + val tokenResponse = generateToken(id, role) + token.update(tokenResponse.refreshToken, jwtProperties.refreshExp) + return TokenResponse(tokenResponse.accessToken, tokenResponse.refreshToken) + } ?: throw InvalidTokenException + } + + /** + * 액세스 토큰과 리프레시 토큰을 생성합니다. + * + * @param userId 사용자 ID + * @param role 사용자 역할 + * @return 생성된 토큰 응답 + */ + fun generateToken( + userId: String, + role: String, + ): TokenResponse { + val accessToken = generateAccessToken(userId, role, ACCESS_KEY, jwtProperties.accessExp) + val refreshToken = generateRefreshToken(role, REFRESH_KEY, jwtProperties.refreshExp) + refreshTokenRepository.save( + RefreshToken(userId, refreshToken, jwtProperties.refreshExp), + ) + return TokenResponse(accessToken, refreshToken) + } + + /** + * 액세스 토큰을 생성합니다. + * + * @param id 사용자 ID + * @param role 사용자 역할 + * @param type 토큰 타입 + * @param exp 만료 시간 (초) + * @return 생성된 액세스 토큰 + */ + private fun generateAccessToken( + id: String, + role: String, + type: String, + exp: Long, + ): String = + Jwts.builder() + .setSubject(id) + .setHeaderParam("typ", type) + .claim("role", role) + .signWith(secretKey, SignatureAlgorithm.HS256) + .setExpiration(Date(System.currentTimeMillis() + exp * 1000)) + .setIssuedAt(Date()) + .compact() + + /** + * 리프레시 토큰을 생성합니다. + * + * @param role 사용자 역할 + * @param type 토큰 타입 + * @param exp 만료 시간 (초) + * @return 생성된 리프레시 토큰 + */ + private fun generateRefreshToken( + role: String, + type: String, + exp: Long, + ): String = + Jwts.builder() + .setHeaderParam("typ", type) + .claim("role", role) + .signWith(secretKey, SignatureAlgorithm.HS256) + .setExpiration(Date(System.currentTimeMillis() + exp * 1000)) + .setIssuedAt(Date()) + .compact() + + /** + * HTTP 요청에서 토큰을 추출합니다. + * + * @param request HTTP 요청 객체 + * @return 추출된 토큰 (없으면 null) + */ + fun resolveToken(request: HttpServletRequest): String? = + request.getHeader(jwtProperties.header)?.also { + if (it.startsWith(jwtProperties.prefix)) { + return it.substring(jwtProperties.prefix.length) + } + } + + /** + * 토큰으로부터 인증 객체를 생성합니다. + * + * @param token JWT 토큰 + * @return Spring Security 인증 객체 + * @throws InvalidTokenException 토큰이 유효하지 않은 경우 + */ + fun authentication(token: String): Authentication? { + val body: Claims = getJws(token).body + if (!isRefreshToken(token)) throw InvalidTokenException + val userDetails: UserDetails = getDetails(body) + return UsernamePasswordAuthenticationToken(userDetails, "", userDetails.authorities) + } + + /** + * 토큰을 파싱하여 JWS 객체를 반환합니다. + * + * @param token 파싱할 JWT 토큰 + * @return 파싱된 JWS 객체 + * @throws ExpiredTokenException 토큰이 만료된 경우 + * @throws InvalidTokenException 토큰이 유효하지 않은 경우 + */ + private fun getJws(token: String): Jws { + return try { + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + } catch (e: ExpiredJwtException) { + throw ExpiredTokenException + } catch (e: Exception) { + throw InvalidTokenException + } + } + + /** + * 토큰이 리프레시 토큰인지 확인합니다. + * + * @param token 확인할 토큰 + * @return 리프레시 토큰 여부 + */ + private fun isRefreshToken(token: String?): Boolean { + return REFRESH_KEY == getJws(token!!).header["typ"].toString() + } + + /** + * 토큰에서 역할 정보를 추출합니다. + * + * @param token JWT 토큰 + * @return 사용자 역할 + */ + fun getRole(token: String) = getJws(token).body["role"].toString() + + /** + * 클레임에서 사용자 상세 정보를 조회합니다. + * + * @param body 토큰의 클레임 정보 + * @return 사용자 상세 정보 + */ + private fun getDetails(body: Claims): UserDetails { + return if (UserRole.USER.toString() == body["role"].toString()) { + authDetailsService.loadUserByUsername(body.subject) + } else { + adminDetailsService.loadUserByUsername(body.subject) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt new file mode 100644 index 0000000..4ef8d7a --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt @@ -0,0 +1,65 @@ +package hs.kr.entrydsm.user.global.utils.pass + +import hs.kr.entrydsm.user.domain.auth.exception.InvalidOkCertConnectException +import kcb.module.v3.OkCert +import kcb.module.v3.exception.OkCertException +import kcb.org.json.JSONObject +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +/** + * KCB 패스 인증 유틸리티 클래스입니다. + */ +@Component +class PassUtil { + companion object { + /** + * KCB OkCert 인스턴스 + */ + private val okCert = OkCert() + + /** + * KCB 인증 대상 환경 (운영환경) + */ + private val TARGET = "PROD" + + /** + * KCB 서비스명 + */ + private val SVC_NAME = "IDS_HS_POPUP_RESULT" + + /** + * 모델 토큰 키명 + */ + private val MODEL_TOKEN = "MDL_TKN" + } + + /** + * KCB CP 코드 + */ + @Value("\${pass.cp-cd}") + private lateinit var cpCd: String + + /** + * KCB 라이선스 키 + */ + @Value("\${pass.license}") + private lateinit var license: String + + /** + * 패스 인증 토큰으로부터 응답 JSON을 가져옵니다. + */ + fun getResponseJson(token: String?): JSONObject? { + val reqJson = JSONObject() + reqJson.put(MODEL_TOKEN, token) + val reqStr = reqJson.toString() + val resultStr: String? = + try { + okCert.callOkCert(TARGET, cpCd, SVC_NAME, license, reqStr) + } catch (e: OkCertException) { + throw InvalidOkCertConnectException + } + assert(resultStr != null) + return JSONObject(resultStr) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt new file mode 100644 index 0000000..8fd2cca --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt @@ -0,0 +1,26 @@ +package hs.kr.entrydsm.user.global.utils.pass + +import hs.kr.entrydsm.user.domain.auth.exception.InvalidUrlException +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +/** + * 리다이렉트 URL의 유효성을 검사하는 클래스입니다. + */ +@Component +class RedirectUrlChecker { + /** + * Pass 인증의 기본 URL + */ + @Value("\${pass.base-url}") + private lateinit var baseUrl: String + + /** + * 리다이렉트 URL이 허용된 기본 URL로 시작하는지 확인합니다. + */ + fun checkRedirectUrl(redirectUrl: String) { + if (!redirectUrl.startsWith(baseUrl)) { + throw InvalidUrlException + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt new file mode 100644 index 0000000..ae9595b --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.user.global.utils.token.dto + +/** + * JWT 토큰 응답을 나타내는 데이터 클래스입니다. + * 로그인 및 토큰 갱신 시 클라이언트에게 반환되는 토큰 정보를 담습니다. + * + * @property accessToken 액세스 토큰 + * @property refreshToken 리프레시 토큰 + */ +data class TokenResponse( + val accessToken: String, + val refreshToken: String, +) diff --git a/casper-user/src/main/proto/user.proto b/casper-user/src/main/proto/user.proto new file mode 100644 index 0000000..1f8b9fb --- /dev/null +++ b/casper-user/src/main/proto/user.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package casper.user; + +option java_package = "hs.kr.entrydsm.casper.user.proto"; +option java_outer_classname = "UserServiceProto"; + +service UserService { + rpc GetUserInfoByUserId(GetUserInfoRequest) returns (GetUserInfoResponse); +} + +message GetUserInfoRequest { + string user_id =1; +} + +message GetUserInfoResponse{ + string id = 1; + string phone_number = 2; + string name = 3; + bool is_parent = 4; + UserRole role = 5; +} + +enum UserRole { + UNSPECIFIED = 0; + ROOT = 1; + USER = 2; + ADMIN = 3; +} \ No newline at end of file diff --git a/casper-user/src/main/resources/application.yml b/casper-user/src/main/resources/application.yml new file mode 100644 index 0000000..980ed7b --- /dev/null +++ b/casper-user/src/main/resources/application.yml @@ -0,0 +1,8 @@ +grpc: + server: + port: 9090 + max-inbound-message-size: 3MB + +app: + encryption: + key: "@qkrwndnjs12" \ No newline at end of file diff --git a/casper-user/src/main/webapp/WEB-INF/lib/OkCert3-java1.5-2.3.1.jar b/casper-user/src/main/webapp/WEB-INF/lib/OkCert3-java1.5-2.3.1.jar new file mode 100644 index 0000000..5b6f595 Binary files /dev/null and b/casper-user/src/main/webapp/WEB-INF/lib/OkCert3-java1.5-2.3.1.jar differ diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 0fb5027..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=Casper-User diff --git a/src/test/kotlin/hs/kr/entrydsm/user/CasperUserApplicationTests.kt b/src/test/kotlin/hs/kr/entrydsm/user/CasperUserApplicationTests.kt deleted file mode 100644 index 163ad77..0000000 --- a/src/test/kotlin/hs/kr/entrydsm/user/CasperUserApplicationTests.kt +++ /dev/null @@ -1,11 +0,0 @@ -package hs.kr.entrydsm.user - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class CasperUserApplicationTests { - @Test - fun contextLoads() { - } -}