From 3c71562317dc716da7e2e486695c75d0eef1666a Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 17:05:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/auth/business/AuthServiceV2.kt | 100 ++++++++++++++++++ .../kotlin/roomescape/auth/docs/AuthAPIV2.kt | 52 +++++++++ .../roomescape/auth/web/AuthControllerV2.kt | 48 +++++++++ .../kotlin/roomescape/auth/web/AuthDTOV2.kt | 24 +++++ 4 files changed, 224 insertions(+) create mode 100644 src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt create mode 100644 src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt create mode 100644 src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt create mode 100644 src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt diff --git a/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt b/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt new file mode 100644 index 00000000..591ddb44 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt @@ -0,0 +1,100 @@ +package roomescape.auth.business + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.admin.business.AdminService +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.auth.web.LoginContext +import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginSuccessResponse +import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.LoginCredentials +import roomescape.common.dto.PrincipalType +import roomescape.member.business.UserService + +private val log: KLogger = KotlinLogging.logger {} + +const val CLAIM_PERMISSION_KEY = "permission" +const val CLAIM_TYPE_KEY = "principal_type" + +@Service +class AuthServiceV2( + private val adminService: AdminService, + private val userService: UserService, + private val loginHistoryService: LoginHistoryService, + private val jwtUtils: JwtUtils, +) { + @Transactional(readOnly = true) + fun login( + request: LoginRequestV2, + context: LoginContext + ): LoginSuccessResponse { + log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}" } + + val extraClaims: MutableMap = mutableMapOf() + + val credentials: LoginCredentials = when (request.principalType) { + PrincipalType.ADMIN -> { + adminService.findCredentialsByAccount(request.account).also { + extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel) + extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.ADMIN) + } + } + + PrincipalType.USER -> { + userService.findCredentialsByAccount(request.account).also { + extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.USER) + } + } + } + + try { + if (credentials.password != request.password) { + log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" } + throw AuthException(AuthErrorCode.LOGIN_FAILED) + } + + val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims) + return LoginSuccessResponse(accessToken) + .also { + log.info { "[AuthService.login] 관리자 로그인 완료: account = ${request.account}, id=${credentials.id}" } + loginHistoryService.createSuccessHistory(credentials.id, PrincipalType.ADMIN, context) + } + } catch (e: Exception) { + log.warn { "[AuthService.login] 관리자 로그인 실패: account = ${request.account}, message=${e.message}" } + loginHistoryService.createFailureHistory(credentials.id, PrincipalType.ADMIN, context) + + throw e + } + } + + @Transactional(readOnly = true) + fun checkLogin(context: CurrentUserContext): CurrentUserContext { + return findContextById(context.id, context.type).also { + if (it != context) { + throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) + } + } + } + + @Transactional(readOnly = true) + fun findContextById(id: Long, type: PrincipalType): CurrentUserContext { + log.info { "[AuthService.checkLogin] 로그인 확인 시작: id=${id}, type=${type}" } + + return when (type) { + PrincipalType.ADMIN -> { + adminService.findContextById(id) + } + + PrincipalType.USER -> { + userService.findContextById(id) + } + }.also { + log.info { "[AuthService.checkLogin] 로그인 확인 완료: id=${id}, type=${type}" } + } + } +} diff --git a/src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt b/src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt new file mode 100644 index 00000000..9cacbdd1 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt @@ -0,0 +1,52 @@ +package roomescape.auth.docs + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RequestBody +import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginSuccessResponse +import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.Public +import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.response.CommonApiResponse + +@Tag(name = "1. 인증 / 인가 API", description = "로그인, 로그아웃 및 로그인 상태를 확인합니다") +interface AuthAPIV2 { + + @Public + @Operation(summary = "로그인") + @ApiResponses( + ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."), + ) + fun login( + @Valid @RequestBody loginRequest: LoginRequestV2, + servletRequest: HttpServletRequest + ): ResponseEntity> + + @Operation(summary = "로그인 상태 확인") + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "입력된 ID / 결과(Boolean)을 반환합니다.", + useReturnTypeSchema = true + ), + ) + fun checkLogin( + @CurrentUser user: CurrentUserContext + ): ResponseEntity> + + @Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"]) + @ApiResponses( + ApiResponse(responseCode = "200"), + ) + fun logout( + @CurrentUser user: CurrentUserContext, + servletResponse: HttpServletResponse + ): ResponseEntity> +} diff --git a/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt b/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt new file mode 100644 index 00000000..38151ccd --- /dev/null +++ b/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt @@ -0,0 +1,48 @@ +package roomescape.auth.web + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import roomescape.auth.business.AuthServiceV2 +import roomescape.auth.docs.AuthAPIV2 +import roomescape.auth.web.support.CurrentUser +import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.response.CommonApiResponse + +@RestController +@RequestMapping("/auth") +class AuthControllerV2( + private val authService: AuthServiceV2, +) : AuthAPIV2 { + + @PostMapping("/login") + override fun login( + loginRequest: LoginRequestV2, + servletRequest: HttpServletRequest + ): ResponseEntity> { + val response = authService.login(request = loginRequest, context = servletRequest.toLoginContext()) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/login/check") + override fun checkLogin( + @CurrentUser user: CurrentUserContext, + ): ResponseEntity> { + val response = authService.checkLogin(user) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PostMapping("/logout") + override fun logout( + @CurrentUser user: CurrentUserContext, + servletResponse: HttpServletResponse + ): ResponseEntity> { + return ResponseEntity.ok().build() + } +} diff --git a/src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt b/src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt new file mode 100644 index 00000000..4bf06274 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt @@ -0,0 +1,24 @@ +package roomescape.auth.web + +import jakarta.servlet.http.HttpServletRequest +import roomescape.common.dto.PrincipalType + +data class LoginContext( + val ipAddress: String, + val userAgent: String, +) + +fun HttpServletRequest.toLoginContext() = LoginContext( + ipAddress = this.remoteAddr, + userAgent = this.getHeader("User-Agent") +) + +data class LoginRequestV2( + val account: String, + val password: String, + val principalType: PrincipalType +) + +data class LoginSuccessResponse( + val accessToken: String +)