diff --git a/.github/workflows/deploy_v2_development_product_api.yml b/.github/workflows/deploy_v2_development_product_api.yml index 7c83123e5..6d5c99614 100644 --- a/.github/workflows/deploy_v2_development_product_api.yml +++ b/.github/workflows/deploy_v2_development_product_api.yml @@ -2,14 +2,20 @@ name: deploy v2 development product api on: workflow_dispatch: - push: + workflow_run: + workflows: [ "product ci pipeline" ] + types: + - completed branches: - main - paths: - - 'bottlenote-batch/**' - - 'bottlenote-mono/**' - - 'bottlenote-observability/**' - - 'bottlenote-product-api/**' + #push: + # branches: + # - main + # paths: + # - 'bottlenote-batch/**' + # - 'bottlenote-mono/**' + # - 'bottlenote-observability/**' + # - 'bottlenote-product-api/**' concurrency: group: "deploy-product" diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 530750668..41d7b9715 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -55,7 +55,6 @@ jobs: destination: ./_site - name: Upload artifact uses: actions/upload-pages-artifact@v3 - deploy: environment: name: github-pages diff --git a/.github/workflows/product_ci_pipeline.yml b/.github/workflows/product_ci_pipeline.yml index 187c92963..02a834ddf 100644 --- a/.github/workflows/product_ci_pipeline.yml +++ b/.github/workflows/product_ci_pipeline.yml @@ -27,13 +27,11 @@ jobs: with: submodules: true token: ${{ secrets.GIT_ACCESS_TOKEN }} - - name: setup jdk uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - - name: setup gradle cache uses: actions/cache@v3 with: @@ -151,7 +149,14 @@ jobs: needs: prepare timeout-minutes: 20 runs-on: ubuntu-latest - + strategy: + fail-fast: true + matrix: + include: + - name: product + task: integration_test + - name: admin + task: admin_integration_test services: docker: image: docker:24.0 @@ -160,7 +165,6 @@ jobs: - 1234:1234 env: DOCKER_TLS_CERTDIR: "" - steps: - name: checkout code uses: actions/checkout@v4 @@ -191,17 +195,17 @@ jobs: */build key: workspace-${{ github.sha }} - - name: run integration tests - run: ./gradlew integration_test + - name: run ${{ matrix.name }} integration tests + run: ./gradlew ${{ matrix.task }} - name: upload test results if: always() uses: actions/upload-artifact@v4 with: - name: integration-test-results-${{ github.run_number }} + name: ${{ matrix.name }}-integration-test-results-${{ github.run_number }} path: | - */build/reports/tests/integration_test/ - */build/test-results/integration_test/ + */build/reports/tests/${{ matrix.task }}/ + */build/test-results/${{ matrix.task }}/ product-ci-final-build: needs: [ unit-tests, rule-tests, integration-tests ] diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index ee90284c2..90a27f9ce 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.0.4 +1.0.5 diff --git a/bottlenote-admin-api/build.gradle.kts b/bottlenote-admin-api/build.gradle.kts index a8365de83..188aa6b2c 100644 --- a/bottlenote-admin-api/build.gradle.kts +++ b/bottlenote-admin-api/build.gradle.kts @@ -11,6 +11,8 @@ val asciidoctorExt: Configuration by configurations.creating dependencies { implementation(project(":bottlenote-mono")) + testImplementation(project(":bottlenote-mono").dependencyProject.sourceSets["test"].output) + implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") @@ -31,9 +33,6 @@ dependencies { // Test - Testcontainers testImplementation(libs.bundles.testcontainers.complete) - - // Test - mono 모듈 TestFactory 참조 - testImplementation(project(":bottlenote-mono").dependencyProject.sourceSets.test.get().output) } sourceSets { @@ -76,3 +75,4 @@ tasks.asciidoctor { } baseDirFollowsSourceFile() } +tasks.register("prepareKotlinBuildScriptModel") {} diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index 69c043eb4..a85ea389c 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -26,6 +26,12 @@ include::api/overview/global-exception.adoc[] ''' +== Auth API + +include::api/admin-auth/auth.adoc[] + +''' + == Alcohol API include::api/admin-alcohols/alcohols.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc new file mode 100644 index 000000000..ab0a57270 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc @@ -0,0 +1,72 @@ +=== 로그인 === + +- 관리자 계정으로 로그인하여 액세스 토큰과 리프레시 토큰을 발급받습니다. + +[source] +---- +POST /admin/api/v1/auth/login +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/auth/login/request-fields.adoc[] +include::{snippets}/admin/auth/login/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/auth/login/response-fields.adoc[] +include::{snippets}/admin/auth/login/http-response.adoc[] + +''' + +=== 토큰 갱신 === + +- 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다. + +[source] +---- +POST /admin/api/v1/auth/refresh +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/auth/refresh/request-fields.adoc[] +include::{snippets}/admin/auth/refresh/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/auth/refresh/response-fields.adoc[] +include::{snippets}/admin/auth/refresh/http-response.adoc[] + +''' + +=== 탈퇴 === + +- 인증된 관리자 계정을 탈퇴 처리합니다. + +[source] +---- +DELETE /admin/api/v1/auth/withdraw +---- + +[discrete] +==== 요청 헤더 ==== + +[discrete] +include::{snippets}/admin/auth/withdraw/request-headers.adoc[] +include::{snippets}/admin/auth/withdraw/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/auth/withdraw/response-fields.adoc[] +include::{snippets}/admin/auth/withdraw/http-response.adoc[] diff --git a/bottlenote-admin-api/src/main/kotlin/app/Application.kt b/bottlenote-admin-api/src/main/kotlin/app/Application.kt index d7feb2dc5..a2775a26a 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/Application.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/Application.kt @@ -2,9 +2,11 @@ package app import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication import java.util.* +@ConfigurationPropertiesScan(basePackages = ["app"]) @EntityScan(basePackages = ["app"]) @SpringBootApplication(scanBasePackages = ["app"]) class AdminApplication diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/config/RootAdminProperties.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/config/RootAdminProperties.kt new file mode 100644 index 000000000..c36efdbb0 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/config/RootAdminProperties.kt @@ -0,0 +1,17 @@ +package app.bottlenote.auth.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder + +@ConfigurationProperties(prefix = "root.admin") +data class RootAdminProperties( + val email: String, + private val password: String +) { + /** + * 주입받은 인코더를 사용하여 비밀번호를 암호화하여 반환합니다. + */ + fun getEncodedPassword(encoder: BCryptPasswordEncoder): String { + return encoder.encode(password) + } +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/persentaton/AuthController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/persentaton/AuthController.kt new file mode 100644 index 000000000..f3514f05d --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/persentaton/AuthController.kt @@ -0,0 +1,76 @@ +package app.bottlenote.auth.persentaton + +import app.bottlenote.auth.config.RootAdminProperties +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.security.SecurityContextUtil +import app.bottlenote.user.dto.request.AdminSignupRequest +import app.bottlenote.user.dto.response.TokenItem +import app.bottlenote.user.exception.UserException +import app.bottlenote.user.exception.UserExceptionCode +import app.bottlenote.user.service.AdminAuthService +import jakarta.validation.Valid +import org.slf4j.LoggerFactory +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.http.ResponseEntity +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/auth") +class AuthController( + private val authService: AdminAuthService, + private val rootAdminProperties: RootAdminProperties, + private val encoder: BCryptPasswordEncoder +) { + private val log = LoggerFactory.getLogger(javaClass) + + @EventListener(ApplicationReadyEvent::class) + fun onApplicationReady() { + val rootAdminIsAlive = authService.rootAdminIsAlive() + log.info("루트 어드민 존재 여부: {}", rootAdminIsAlive) + if (!rootAdminIsAlive) { + log.info("루트 어드민 초기 생성 로직 호출") + val email = rootAdminProperties.email + val encodedPassword = rootAdminProperties.getEncodedPassword(encoder) + authService.initRootAdmin(email, encodedPassword) + } + } + + @PostMapping("/login") + fun login(@RequestBody request: LoginRequest): ResponseEntity<*> { + val tokenItem: TokenItem = authService.login(request.email, request.password) + return GlobalResponse.ok(tokenItem) + } + + @PostMapping("/refresh") + fun refresh(@RequestBody request: RefreshRequest): ResponseEntity<*> { + val tokenItem: TokenItem = authService.refresh(request.refreshToken) + return GlobalResponse.ok(tokenItem) + } + + @PostMapping("/signup") + fun signup(@RequestBody @Valid request: AdminSignupRequest): ResponseEntity<*> { + val requesterId = SecurityContextUtil.getAdminUserIdByContext() + .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } + val response = authService.signup(requesterId, request) + return GlobalResponse.ok(response) + } + + @DeleteMapping("/withdraw") + fun withdraw(): ResponseEntity<*> { + val adminId = SecurityContextUtil.getAdminUserIdByContext() + .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } + authService.withdraw(adminId) + return GlobalResponse.ok(mapOf("message" to "탈퇴 처리되었습니다.")) + } + + data class LoginRequest( + val email: String, + val password: String + ) + + data class RefreshRequest( + val refreshToken: String + ) +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt b/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt index ea4551b6f..5c3c2c5d0 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt @@ -1,32 +1,43 @@ package app.global.security +import app.bottlenote.global.security.jwt.AdminJwtAuthenticationFilter +import app.bottlenote.global.security.jwt.AdminJwtAuthenticationManager import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource import org.springframework.web.cors.UrlBasedCorsConfigurationSource @Configuration @EnableWebSecurity -class SecurityConfig { +class SecurityConfig( + private val adminJwtAuthenticationManager: AdminJwtAuthenticationManager +) { @Bean fun filterChain(http: HttpSecurity): SecurityFilterChain { return http .csrf { it.disable() } - .cors { corsConfigurationSource() } + .cors { it.configurationSource(corsConfigurationSource()) } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .formLogin { it.disable() } .httpBasic { it.disable() } .authorizeHttpRequests { auth -> - run { - auth.anyRequest().permitAll() - - } + auth + .requestMatchers("/auth/login", "/auth/refresh").permitAll() + .requestMatchers("/actuator/**").permitAll() + .anyRequest().authenticated() } + .addFilterBefore( + AdminJwtAuthenticationFilter(adminJwtAuthenticationManager), + UsernamePasswordAuthenticationFilter::class.java + ) .build() } diff --git a/bottlenote-admin-api/src/main/resources/application.yml b/bottlenote-admin-api/src/main/resources/application.yml index 605c542ab..07ecf73a4 100644 --- a/bottlenote-admin-api/src/main/resources/application.yml +++ b/bottlenote-admin-api/src/main/resources/application.yml @@ -58,17 +58,20 @@ schedules: sync: enable: false +root: + admin: + email: ${ROOT_ADMIN_EMAIL:email@email.com} + password: ${ROOT_ADMIN_PASSWORD:email@email.com} + --- # default/local 프로파일 (동일 설정) spring: config: activate: on-profile: default,local - logging: level: root: info app.bottlenote: info - management: tracing: enabled: false @@ -76,7 +79,6 @@ management: metrics: export: enabled: false - server: tomcat: threads: diff --git a/bottlenote-admin-api/src/test/kotlin/app/ApplicationContextStartupIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/ApplicationContextStartupIntegrationTest.kt index 1e73d54ff..5d2b74386 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/ApplicationContextStartupIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/ApplicationContextStartupIntegrationTest.kt @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.testcontainers.containers.MySQLContainer -@Tag("integration") +@Tag("admin_integration") @DisplayName("[integration] Admin API 컨텍스트 로드 테스트") class ApplicationContextStartupIntegrationTest : IntegrationTestSupport() { diff --git a/bottlenote-admin-api/src/test/kotlin/app/IntegrationTestSupport.kt b/bottlenote-admin-api/src/test/kotlin/app/IntegrationTestSupport.kt index 3f2f08511..5c59abfa6 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/IntegrationTestSupport.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/IntegrationTestSupport.kt @@ -1,9 +1,16 @@ package app -import app.helper.TestContainersConfig +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.security.jwt.JwtTokenProvider +import app.bottlenote.operation.utils.DataInitializer +import app.bottlenote.operation.utils.TestContainersConfig +import app.bottlenote.user.domain.AdminUser +import app.bottlenote.user.dto.response.TokenItem +import app.bottlenote.user.fixture.AdminUserTestFactory import com.fasterxml.jackson.databind.ObjectMapper import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.Logger +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Tag import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase @@ -12,10 +19,11 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.Import import org.springframework.test.context.ActiveProfiles import org.springframework.test.web.servlet.assertj.MockMvcTester +import org.springframework.test.web.servlet.assertj.MvcTestResult @Import(TestContainersConfig::class) @ActiveProfiles("test") -@Tag("integration") +@Tag("admin_integration") @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -31,4 +39,48 @@ abstract class IntegrationTestSupport { @Autowired protected lateinit var mockMvcTester: MockMvcTester + + @Autowired + protected lateinit var adminUserTestFactory: AdminUserTestFactory + + @Autowired + protected lateinit var jwtTokenProvider: JwtTokenProvider + + @Autowired + protected lateinit var dataInitializer: DataInitializer + + @AfterEach + fun cleanUpAfterEach() { + dataInitializer.deleteAll() + } + + /** + * AdminUser에 대한 토큰 생성 + */ + protected fun createToken(admin: AdminUser): TokenItem { + return jwtTokenProvider.generateAdminToken(admin.email, admin.roles, admin.id) + } + + /** + * AdminUser에 대한 액세스 토큰 문자열 반환 + */ + protected fun getAccessToken(admin: AdminUser): String { + return createToken(admin).accessToken() + } + + /** + * MvcTestResult에서 GlobalResponse 파싱 + */ + protected fun parseResponse(result: MvcTestResult): GlobalResponse { + val responseString = result.response.contentAsString + return mapper.readValue(responseString, GlobalResponse::class.java) + } + + /** + * MvcTestResult에서 data 필드를 지정 타입으로 변환 + */ + protected fun extractData(result: MvcTestResult, dataType: Class): T { + val response = parseResponse(result) + return mapper.convertValue(response.data, dataType) + } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt index 7542637b9..9d49ef6c0 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt @@ -17,6 +17,7 @@ import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfi import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.operation.preprocess.Preprocessors.* import org.springframework.restdocs.payload.JsonFieldType import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath import org.springframework.restdocs.payload.PayloadDocumentation.responseFields @@ -78,6 +79,8 @@ class AdminAlcoholsControllerDocsTest { .apply( document( "admin/alcohols/search", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), queryParameters( parameterWithName("keyword").optional().description("검색어 (한글/영문 이름 검색)"), parameterWithName("category").optional().description("카테고리 그룹 필터 (예: SINGLE_MALT, BLEND 등)"), diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt new file mode 100644 index 000000000..61e987371 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt @@ -0,0 +1,237 @@ +package app.docs.auth + +import app.bottlenote.auth.config.RootAdminProperties +import app.bottlenote.auth.persentaton.AuthController +import app.bottlenote.global.security.SecurityContextUtil +import app.bottlenote.user.constant.AdminRole +import app.bottlenote.user.dto.request.AdminSignupRequest +import app.bottlenote.user.dto.response.AdminSignupResponse +import app.bottlenote.user.service.AdminAuthService +import app.helper.auth.AuthHelper +import com.fasterxml.jackson.databind.ObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.BDDMockito.given +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.Mockito.anyString +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.MediaType +import org.springframework.restdocs.headers.HeaderDocumentation.headerWithName +import org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.operation.preprocess.Preprocessors.* +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.* +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.assertj.MockMvcTester +import java.util.* + +@WebMvcTest( + controllers = [AuthController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("Admin Auth 컨트롤러 RestDocs 테스트") +class AuthControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @MockitoBean + private lateinit var authService: AdminAuthService + + @MockitoBean + private lateinit var rootAdminProperties: RootAdminProperties + + @MockitoBean + private lateinit var passwordEncoder: BCryptPasswordEncoder + + @Test + @DisplayName("어드민 로그인") + fun login() { + // given + val tokenItem = AuthHelper.createTokenItem() + given(authService.login(anyString(), anyString())).willReturn(tokenItem) + + val request = mapOf("email" to "admin@bottlenote.com", "password" to "password123") + + // when & then + assertThat( + mvc.post().uri("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/auth/login", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("관리자 이메일"), + fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("액세스 토큰"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("리프레시 토큰"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + + @Test + @DisplayName("토큰 갱신") + fun refresh() { + // given + val tokenItem = AuthHelper.createTokenItem() + given(authService.refresh(anyString())).willReturn(tokenItem) + + val request = mapOf("refreshToken" to "existing_refresh_token") + + // when & then + assertThat( + mvc.post().uri("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/auth/refresh", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("리프레시 토큰") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새 액세스 토큰"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("새 리프레시 토큰"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + + @Test + @DisplayName("어드민 탈퇴") + fun withdraw() { + // given + Mockito.mockStatic(SecurityContextUtil::class.java).use { mockedStatic: MockedStatic -> + mockedStatic.`when`> { SecurityContextUtil.getAdminUserIdByContext() } + .thenReturn(Optional.of(1L)) + + // when & then + assertThat( + mvc.delete().uri("/auth/withdraw") + .header("Authorization", "Bearer test_access_token") + ) + .hasStatusOk() + .apply( + document( + "admin/auth/withdraw", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("처리 결과 메시지"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Test + @DisplayName("어드민 회원가입") + fun signup() { + // given + val response = AdminSignupResponse( + 1L, + "new@bottlenote.com", + "새 어드민", + listOf(AdminRole.PARTNER, AdminRole.COMMUNITY_MANAGER) + ) + given(authService.signup(anyLong(), org.mockito.ArgumentMatchers.any(AdminSignupRequest::class.java))).willReturn(response) + + Mockito.mockStatic(SecurityContextUtil::class.java).use { mockedStatic: MockedStatic -> + mockedStatic.`when`> { SecurityContextUtil.getAdminUserIdByContext() } + .thenReturn(Optional.of(1L)) + + val request = mapOf( + "email" to "new@bottlenote.com", + "password" to "password123", + "name" to "새 어드민", + "roles" to listOf("PARTNER", "COMMUNITY_MANAGER") + ) + + // when & then + assertThat( + mvc.post().uri("/auth/signup") + .header("Authorization", "Bearer test_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/auth/signup", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰") + ), + requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("어드민 이메일"), + fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호 (8~35자)"), + fieldWithPath("name").type(JsonFieldType.STRING).description("어드민 이름 (2~50자)"), + fieldWithPath("roles").type(JsonFieldType.ARRAY).description("역할 목록 (ROOT_ADMIN, PARTNER, CLIENT, BAR_OWNER, COMMUNITY_MANAGER)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data.adminId").type(JsonFieldType.NUMBER).description("생성된 어드민 ID"), + fieldWithPath("data.email").type(JsonFieldType.STRING).description("어드민 이메일"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("어드민 이름"), + fieldWithPath("data.roles").type(JsonFieldType.ARRAY).description("부여된 역할 목록"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/rule.md b/bottlenote-admin-api/src/test/kotlin/app/docs/rule.md new file mode 100644 index 000000000..8f34b25e2 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/rule.md @@ -0,0 +1,21 @@ +# Admin REST Docs 작성 규칙 + +## 테스트 작성 규칙 + +- `excludeAutoConfiguration = [SecurityAutoConfiguration::class]`로 Security 예외 설정 필수 (문서화 목적이므로 인증 불필요) +- JSON 가독성을 위해 모든 `document()`에 `preprocessRequest(prettyPrint())`, `preprocessResponse(prettyPrint())` 적용 필수 +- meta 필드(`serverVersion`, `serverEncoding`, `serverResponseTime`, `serverPathVersion`)는 `.ignored()` 처리 +- snippets 경로는 `admin/{도메인}/{기능}` 형식 사용 (예: `admin/auth/login`) + +## Asciidoc 문서 작성 규칙 + +- `[discrete]`는 목차(TOC)에서 제외할 제목에 사용 (하위 섹션 제목, include 앞) +- adoc 파일 경로는 `src/docs/asciidoc/api/admin-{도메인}/{기능}.adoc` 형식 +- API 간 구분은 `'''` 사용 +- 새 API 문서 추가 시 `admin-api.adoc`에 include 추가 필수 + +## include 순서 + +- 요청: `request-fields.adoc` -> `http-request.adoc` +- 응답: `response-fields.adoc` -> `http-response.adoc` +- 쿼리 파라미터: `query-parameters.adoc` -> `curl-request.adoc` -> `http-request.adoc` \ No newline at end of file diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/TestContainersConfig.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/TestContainersConfig.kt deleted file mode 100644 index 607cf3014..000000000 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/TestContainersConfig.kt +++ /dev/null @@ -1,33 +0,0 @@ -package app.helper - -import com.redis.testcontainers.RedisContainer -import org.springframework.boot.test.context.TestConfiguration -import org.springframework.boot.testcontainers.service.connection.ServiceConnection -import org.springframework.context.annotation.Bean -import org.testcontainers.containers.MySQLContainer -import org.testcontainers.utility.DockerImageName - -@TestConfiguration(proxyBeanMethods = false) -class TestContainersConfig { - - @Bean - @ServiceConnection - fun mysqlContainer(): MySQLContainer = - MySQLContainer("mysql:8.0.32").apply { - withReuse(true) - withDatabaseName("bottlenote") - withUsername("root") - withPassword("root") - withInitScripts( - "storage/mysql/init/00-init-config-table.sql", - "storage/mysql/init/01-init-core-table.sql" - ) - } - - @Bean - @ServiceConnection - fun redisContainer(): RedisContainer = - RedisContainer(DockerImageName.parse("redis:7.0.12")).apply { - withReuse(true) - } -} diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/auth/AuthHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/auth/AuthHelper.kt new file mode 100644 index 000000000..1a3bef9a6 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/auth/AuthHelper.kt @@ -0,0 +1,14 @@ +package app.helper.auth + +import app.bottlenote.user.dto.response.TokenItem +import org.apache.commons.lang3.RandomStringUtils + +object AuthHelper { + + fun createTokenItem(): TokenItem { + return TokenItem( + RandomStringUtils.randomAlphanumeric(32), + RandomStringUtils.randomAlphanumeric(32) + ) + } +} \ No newline at end of file diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt index a160f499f..579c62c6b 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt @@ -6,6 +6,7 @@ import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.fixture.AlcoholTestFactory import app.bottlenote.global.service.cursor.SortOrder import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test @@ -15,17 +16,23 @@ import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.EnumSource import org.junit.jupiter.params.provider.MethodSource import org.springframework.beans.factory.annotation.Autowired -import org.springframework.transaction.annotation.Transactional import java.util.stream.Stream -@Transactional -@Tag("integration") +@Tag("admin_integration") @DisplayName("[integration] Admin Alcohol API 통합 테스트") class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var alcoholTestFactory: AlcoholTestFactory + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + companion object { @JvmStatic fun keywordSearchTestCases(): Stream = Stream.of( @@ -52,7 +59,10 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { alcoholTestFactory.persistAlcohols(5) // when & then - assertThat(mockMvcTester.get().uri("/alcohols")) + assertThat( + mockMvcTester.get().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + ) .hasStatusOk() .bodyJson() .extractingPath("$.success").isEqualTo(true) @@ -69,6 +79,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( mockMvcTester.get().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") .param("keyword", keyword) ) .hasStatusOk() @@ -86,6 +97,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( mockMvcTester.get().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") .param("category", category.name) ) .hasStatusOk() @@ -104,6 +116,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( mockMvcTester.get().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") .param("sortType", sortType.name) .param("sortOrder", sortOrder.name) ) @@ -127,6 +140,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( mockMvcTester.get().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") .param("page", page.toString()) .param("size", size.toString()) ) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt new file mode 100644 index 000000000..74b73382a --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt @@ -0,0 +1,414 @@ +package app.integration.auth + +import app.IntegrationTestSupport +import app.bottlenote.user.constant.AdminRole +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.springframework.http.MediaType + +@Tag("admin_integration") +@DisplayName("[integration] Admin Auth API 통합 테스트") +class AdminAuthIntegrationTest : IntegrationTestSupport() { + + @Nested + @DisplayName("로그인 API") + inner class LoginTest { + + @Test + @DisplayName("올바른 이메일과 비밀번호로 로그인에 성공한다") + fun loginSuccess() { + // given + val email = "test@bottlenote.com" + val password = "password123" + adminUserTestFactory.persistRootAdmin(email, password) + + val request = mapOf("email" to email, "password" to password) + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + + assertThat( + mockMvcTester.post() + .uri("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.accessToken").isNotNull() + } + + @Test + @DisplayName("잘못된 비밀번호로 로그인 시 실패한다") + fun loginFailWithWrongPassword() { + // given + val email = "test@bottlenote.com" + val password = "password123" + adminUserTestFactory.persistRootAdmin(email, password) + + val request = mapOf("email" to email, "password" to "wrongPassword") + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.success").isEqualTo(false) + } + + @Test + @DisplayName("존재하지 않는 이메일로 로그인 시 실패한다") + fun loginFailWithNonExistentEmail() { + // given + val request = mapOf("email" to "nonexistent@bottlenote.com", "password" to "password123") + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.success").isEqualTo(false) + } + + @Test + @DisplayName("비활성화된 어드민 계정으로 로그인 시 실패한다") + fun loginFailWithInactiveAdmin() { + // given + val email = "inactive@bottlenote.com" + val password = "password123" + adminUserTestFactory.persistInactiveAdmin(email, password) + + val request = mapOf("email" to email, "password" to password) + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.success").isEqualTo(false) + } + + @Test + @DisplayName("다중 역할을 가진 어드민도 로그인에 성공한다") + fun loginSuccessWithMultipleRoles() { + // given + val roles = listOf(AdminRole.PARTNER, AdminRole.COMMUNITY_MANAGER) + val admin = adminUserTestFactory.persistMultiRoleAdmin(roles) + + val request = mapOf("email" to admin.email, "password" to "password123") + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + } + + @Nested + @DisplayName("토큰 갱신 API") + inner class RefreshTest { + + @Test + @DisplayName("유효한 리프레시 토큰으로 토큰 갱신에 성공한다") + fun refreshSuccess() { + // given + val email = "refresh-test@bottlenote.com" + val password = "password123" + adminUserTestFactory.persistRootAdmin(email, password) + + // 로그인해서 토큰 획득 + val loginRequest = mapOf("email" to email, "password" to password) + val loginResult = mockMvcTester.post() + .uri("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(loginRequest)) + .exchange() + + val loginResponse = mapper.readTree(loginResult.response.contentAsString) + val refreshToken = loginResponse.path("data").path("refreshToken").asText() + + val request = mapOf("refreshToken" to refreshToken) + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.accessToken").isNotNull() + } + + @Test + @DisplayName("유효하지 않은 리프레시 토큰으로 갱신 시 실패한다") + fun refreshFailWithInvalidToken() { + // given + val request = mapOf("refreshToken" to "invalid.refresh.token") + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.success").isEqualTo(false) + } + } + + @Nested + @DisplayName("탈퇴 API") + inner class WithdrawTest { + + @Test + @DisplayName("인증된 어드민이 탈퇴에 성공한다") + fun withdrawSuccess() { + // given + val admin = adminUserTestFactory.persistRootAdmin() + val accessToken = getAccessToken(admin) + + // when & then + assertThat( + mockMvcTester.delete() + .uri("/auth/withdraw") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.message").isEqualTo("탈퇴 처리되었습니다.") + } + } + + @Nested + @DisplayName("회원가입 API") + inner class SignupTest { + + @Test + @DisplayName("인증된 어드민이 새 어드민 계정을 생성할 수 있다") + fun signupSuccess() { + // given + val admin = adminUserTestFactory.persistRootAdmin() + val accessToken = getAccessToken(admin) + + val request = mapOf( + "email" to "new@bottlenote.com", + "password" to "password123", + "name" to "새 어드민", + "roles" to listOf("PARTNER") + ) + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/signup") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + + assertThat( + mockMvcTester.post() + .uri("/auth/signup") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request.plus("email" to "another@bottlenote.com"))) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.email").isEqualTo("another@bottlenote.com") + } + + @Test + @DisplayName("다중 역할을 가진 어드민 계정을 생성할 수 있다") + fun signupWithMultipleRoles() { + // given + val admin = adminUserTestFactory.persistRootAdmin() + val accessToken = getAccessToken(admin) + + val request = mapOf( + "email" to "multi-role@bottlenote.com", + "password" to "password123", + "name" to "다중 역할 어드민", + "roles" to listOf("PARTNER", "COMMUNITY_MANAGER") + ) + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/signup") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.roles").isEqualTo(listOf("PARTNER", "COMMUNITY_MANAGER")) + } + + @Test + @DisplayName("ROOT_ADMIN이 ROOT_ADMIN 역할을 포함한 어드민을 생성할 수 있다") + fun rootAdminCanCreateRootAdmin() { + // given + val admin = adminUserTestFactory.persistRootAdmin() + val accessToken = getAccessToken(admin) + + val request = mapOf( + "email" to "new-root@bottlenote.com", + "password" to "password123", + "name" to "새 루트 어드민", + "roles" to listOf("ROOT_ADMIN") + ) + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/signup") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.roles").isEqualTo(listOf("ROOT_ADMIN")) + } + + @Test + @DisplayName("ROOT_ADMIN이 아닌 어드민이 ROOT_ADMIN 역할을 부여하려 하면 실패한다") + fun nonRootAdminCannotAssignRootAdminRole() { + // given + val admin = adminUserTestFactory.persistPartnerAdmin() + val accessToken = getAccessToken(admin) + + val request = mapOf( + "email" to "root-attempt@bottlenote.com", + "password" to "password123", + "name" to "루트 시도", + "roles" to listOf("ROOT_ADMIN") + ) + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/signup") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.success").isEqualTo(false) + } + + @Test + @DisplayName("인증되지 않은 사용자는 회원가입 API를 호출할 수 없다") + fun signupFailWithoutAuth() { + // given + val request = mapOf( + "email" to "no-auth@bottlenote.com", + "password" to "password123", + "name" to "인증 없는 사용자", + "roles" to listOf("PARTNER") + ) + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("중복된 이메일로 회원가입 시 실패한다") + fun signupFailWithDuplicateEmail() { + // given + val existingEmail = "existing@bottlenote.com" + adminUserTestFactory.persistAdminUser(existingEmail, "password123", "기존 어드민", listOf(AdminRole.PARTNER)) + + val admin = adminUserTestFactory.persistRootAdmin() + val accessToken = getAccessToken(admin) + + val request = mapOf( + "email" to existingEmail, + "password" to "password123", + "name" to "중복 시도", + "roles" to listOf("PARTNER") + ) + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/signup") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.success").isEqualTo(false) + } + + @Test + @DisplayName("역할이 비어있으면 회원가입 시 실패한다") + fun signupFailWithEmptyRoles() { + // given + val admin = adminUserTestFactory.persistRootAdmin() + val accessToken = getAccessToken(admin) + + val request = mapOf( + "email" to "empty-roles@bottlenote.com", + "password" to "password123", + "name" to "역할 없음", + "roles" to emptyList() + ) + + // when & then + assertThat( + mockMvcTester.post() + .uri("/auth/signup") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } +} diff --git a/bottlenote-admin-api/src/test/resources/application-test.yml b/bottlenote-admin-api/src/test/resources/application-test.yml index 35f0cb12e..92ffbc168 100644 --- a/bottlenote-admin-api/src/test/resources/application-test.yml +++ b/bottlenote-admin-api/src/test/resources/application-test.yml @@ -82,3 +82,9 @@ management: url: http://localhost:4318/v1/metrics logging: endpoint: http://localhost:4318/v1/logs + +# Root Admin Configuration +root: + admin: + email: ${ROOT_ADMIN_EMAIL:email@email.com} + password: ${ROOT_ADMIN_PASSWORD:email@email.com} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/CustomAdminUserContext.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/CustomAdminUserContext.java new file mode 100644 index 000000000..a6c4d03a0 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/CustomAdminUserContext.java @@ -0,0 +1,31 @@ +package app.bottlenote.global.security; + +import app.bottlenote.user.constant.AdminRole; +import app.bottlenote.user.domain.AdminUser; +import java.util.List; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; + +@Getter +public class CustomAdminUserContext extends User { + + private final Long id; + private final String name; + private final List roles; + + public CustomAdminUserContext(AdminUser adminUser, List authorities) { + super(adminUser.getEmail(), "", authorities); + this.id = adminUser.getId(); + this.name = adminUser.getName(); + this.roles = adminUser.getRoles(); + } + + public boolean hasRole(AdminRole role) { + return roles.contains(role); + } + + public boolean isRootAdmin() { + return hasRole(AdminRole.ROOT_ADMIN); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/CustomAdminUserDetailsService.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/CustomAdminUserDetailsService.java new file mode 100644 index 000000000..fdb0506c2 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/CustomAdminUserDetailsService.java @@ -0,0 +1,44 @@ +package app.bottlenote.global.security; + +import app.bottlenote.user.constant.AdminRole; +import app.bottlenote.user.constant.UserStatus; +import app.bottlenote.user.domain.AdminUser; +import app.bottlenote.user.domain.AdminUserRepository; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class CustomAdminUserDetailsService implements UserDetailsService { + + private final AdminUserRepository adminUserRepository; + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + AdminUser adminUser = + adminUserRepository + .findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("관리자를 찾을 수 없습니다: " + email)); + + if (adminUser.getStatus() != UserStatus.ACTIVE) { + throw new UsernameNotFoundException("비활성화된 관리자 계정입니다: " + email); + } + + List authorities = + adminUser.getRoles().stream() + .map(AdminRole::name) + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .collect(Collectors.toList()); + + return new CustomAdminUserContext(adminUser, authorities); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/SecurityContextUtil.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/SecurityContextUtil.java index 7520d58a9..2db8613cd 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/security/SecurityContextUtil.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/SecurityContextUtil.java @@ -71,4 +71,33 @@ public static Optional getUserIdByContext() { return Optional.of(id); } + + /** + * 현재 세션에서 인증된 어드민 사용자의 ID를 반환합니다. + * + * @return the admin user id by context + */ + public static Optional getAdminUserIdByContext() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null) { + log.debug(contextNotFount); + return Optional.empty(); + } + + Object principal = authentication.getPrincipal(); + + if (!(principal instanceof CustomAdminUserContext adminContext)) { + log.debug("인증된 사용자의 정보가 CustomAdminUserContext의 인스턴스가 아닙니다."); + return Optional.empty(); + } + + Long id = adminContext.getId(); + if (id == null) { + log.debug("어드민 사용자 ID가 null입니다."); + return Optional.empty(); + } + + return Optional.of(id); + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/AdminJwtAuthenticationFilter.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/AdminJwtAuthenticationFilter.java new file mode 100644 index 000000000..f3fc5c697 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/AdminJwtAuthenticationFilter.java @@ -0,0 +1,97 @@ +package app.bottlenote.global.security.jwt; + +import static java.util.Objects.requireNonNullElse; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@RequiredArgsConstructor +public class AdminJwtAuthenticationFilter extends OncePerRequestFilter { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BEARER_PREFIX = "Bearer "; + + private final AdminJwtAuthenticationManager adminJwtAuthenticationManager; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + + final String method = request.getMethod(); + final String path = request.getServletPath(); + String token = resolveToken(request).orElse(null); + + log.debug("Admin JWT 필터 수행 >> {} : {}", method, path); + + try { + if (token == null || token.isBlank()) { + log.debug("Admin API 접근 시 토큰이 필요합니다. path: {}", path); + request.setAttribute( + "exception", new CustomJwtException(CustomJwtExceptionCode.EMPTY_JWT_TOKEN)); + filterChain.doFilter(request, response); + return; + } + + if (!JwtTokenValidator.validateToken(token)) { + log.warn("유효하지 않은 Admin 토큰입니다."); + request.setAttribute("exception", new MalformedJwtException("토큰이 유효하지 않습니다.")); + filterChain.doFilter(request, response); + return; + } + + Authentication authentication = adminJwtAuthenticationManager.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.warn("잘못된 JWT 서명입니다."); + request.setAttribute("exception", e); + } catch (ExpiredJwtException e) { + log.warn("만료된 JWT 토큰입니다."); + request.setAttribute("exception", e); + } catch (UnsupportedJwtException e) { + log.warn("지원되지 않는 JWT 토큰입니다."); + request.setAttribute("exception", e); + } catch (IllegalArgumentException e) { + log.warn("JWT 토큰이 잘못되었습니다."); + request.setAttribute("exception", e); + } catch (CustomJwtException e) { + log.warn("JWT 예외: {}", e.getMessage()); + request.setAttribute("exception", e); + } + + filterChain.doFilter(request, response); + } + + private Optional resolveToken(HttpServletRequest request) { + String bearerToken = requireNonNullElse(request.getHeader(AUTHORIZATION_HEADER), ""); + if (bearerToken.startsWith(BEARER_PREFIX)) { + return Optional.of(bearerToken.substring(BEARER_PREFIX.length())); + } + return Optional.empty(); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + List excludePaths = List.of("/auth/login", "/auth/refresh", "/actuator"); + return excludePaths.stream().anyMatch(path::contains); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/AdminJwtAuthenticationManager.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/AdminJwtAuthenticationManager.java new file mode 100644 index 000000000..f76d0cd50 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/AdminJwtAuthenticationManager.java @@ -0,0 +1,77 @@ +package app.bottlenote.global.security.jwt; + +import static app.bottlenote.global.security.jwt.JwtTokenProvider.KEY_ROLES; +import static app.bottlenote.user.exception.UserExceptionCode.INVALID_TOKEN; +import static java.util.stream.Collectors.toList; + +import app.bottlenote.global.security.CustomAdminUserDetailsService; +import app.bottlenote.user.exception.UserException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Arrays; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class AdminJwtAuthenticationManager { + + private final CustomAdminUserDetailsService customAdminUserDetailsService; + private final Key secretKey; + + public AdminJwtAuthenticationManager( + @Value("${security.jwt.secret-key}") String secret, + CustomAdminUserDetailsService customAdminUserDetailsService) { + this.customAdminUserDetailsService = customAdminUserDetailsService; + byte[] keyBytes = Decoders.BASE64.decode(secret); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + /** Admin 토큰을 받아 Authentication 객체를 생성 */ + public Authentication getAuthentication(String accessToken) { + Claims claims = parseClaims(accessToken); + log.debug("Admin 클레임 정보 : {}", claims.toString()); + + Boolean isAdmin = claims.get("isAdmin", Boolean.class); + if (isAdmin == null || !isAdmin) { + throw new UserException(INVALID_TOKEN); + } + + String rolesStr = claims.get(KEY_ROLES, String.class); + if (rolesStr == null) { + throw new UserException(INVALID_TOKEN); + } + + List authorities = + Arrays.stream(rolesStr.split(",")) + .map(String::trim) + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .collect(toList()); + + UserDetails userDetails = customAdminUserDetailsService.loadUserByUsername(claims.getSubject()); + return new UsernamePasswordAuthenticationToken(userDetails, "", authorities); + } + + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java index 09390b57a..4ec3f0ab4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java @@ -1,5 +1,6 @@ package app.bottlenote.global.security.jwt; +import app.bottlenote.user.constant.AdminRole; import app.bottlenote.user.constant.UserType; import app.bottlenote.user.dto.response.TokenItem; import io.jsonwebtoken.Claims; @@ -9,6 +10,8 @@ import io.jsonwebtoken.security.Keys; import java.security.Key; import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -112,4 +115,42 @@ private Claims createClaims(String userEmail, UserType role, Long userId) { claims.put("userId", userId); return claims; } + + /** 어드민용 토큰 생성 메서드 (다중 역할 지원) */ + public TokenItem generateAdminToken(String email, List roles, Long adminId) { + String accessToken = createAdminAccessToken(email, roles, adminId); + String refreshToken = createAdminRefreshToken(email, roles, adminId); + return TokenItem.builder().accessToken(accessToken).refreshToken(refreshToken).build(); + } + + public String createAdminAccessToken(String email, List roles, Long adminId) { + Claims claims = createAdminClaims(email, roles, adminId); + Date now = new Date(); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME)) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); + } + + public String createAdminRefreshToken(String email, List roles, Long adminId) { + Claims claims = createAdminClaims(email, roles, adminId); + Date now = new Date(); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME)) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); + } + + private Claims createAdminClaims(String email, List roles, Long adminId) { + Claims claims = Jwts.claims().setSubject(email); + String rolesString = roles.stream().map(AdminRole::name).collect(Collectors.joining(",")); + claims.put(KEY_ROLES, rolesString); + claims.put("userId", adminId); + claims.put("isAdmin", true); + return claims; + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/AdminRoleConverter.java b/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/AdminRoleConverter.java new file mode 100644 index 000000000..b0adf4acd --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/AdminRoleConverter.java @@ -0,0 +1,47 @@ +package app.bottlenote.global.service.converter; + +import static app.bottlenote.user.exception.UserExceptionCode.JSON_PARSING_EXCEPTION; + +import app.bottlenote.user.constant.AdminRole; +import app.bottlenote.user.exception.UserException; +import com.amazonaws.util.CollectionUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.Collections; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Converter +public class AdminRoleConverter implements AttributeConverter, String> { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List roles) { + if (CollectionUtils.isNullOrEmpty(roles)) { + return "[]"; + } + try { + return objectMapper.writeValueAsString(roles); + } catch (JsonProcessingException e) { + throw new UserException(JSON_PARSING_EXCEPTION); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(dbData, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + log.error("Failed to parse AdminRole JSON data: {}", dbData, e); + throw new UserException(JSON_PARSING_EXCEPTION); + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/constant/AdminRole.java b/bottlenote-mono/src/main/java/app/bottlenote/user/constant/AdminRole.java new file mode 100644 index 000000000..bcd7b22bc --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/constant/AdminRole.java @@ -0,0 +1,18 @@ +package app.bottlenote.user.constant; + +import lombok.Getter; + +@Getter +public enum AdminRole { + ROOT_ADMIN("최고 관리자"), + PARTNER("파트너사"), + CLIENT("고객사"), + BAR_OWNER("바/매장 사장님"), + COMMUNITY_MANAGER("커뮤니티 매니저"); + + private final String description; + + AdminRole(String description) { + this.description = description; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/constant/GenderType.java b/bottlenote-mono/src/main/java/app/bottlenote/user/constant/GenderType.java index c4a4196d8..5064095ae 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/constant/GenderType.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/constant/GenderType.java @@ -4,6 +4,7 @@ import java.util.stream.Stream; public enum GenderType { + NONE, MALE, FEMALE; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/domain/AdminUser.java b/bottlenote-mono/src/main/java/app/bottlenote/user/domain/AdminUser.java new file mode 100644 index 000000000..96a868646 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/domain/AdminUser.java @@ -0,0 +1,103 @@ +package app.bottlenote.user.domain; + +import app.bottlenote.common.domain.BaseTimeEntity; +import app.bottlenote.global.service.converter.AdminRoleConverter; +import app.bottlenote.user.constant.AdminRole; +import app.bottlenote.user.constant.UserStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Builder +@Getter +@Comment("관리자 사용자 테이블") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity(name = "admin_users") +@Table(name = "admin_users") +public class AdminUser extends BaseTimeEntity { + + @Id + @Comment("관리자 ID") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("관리자 이메일 (로그인 ID)") + @Column(name = "email", nullable = false, unique = true) + private String email; + + @Comment("관리자 비밀번호") + @Column(name = "password", nullable = false) + private String password; + + @Comment("관리자 이름") + @Column(name = "name", nullable = false) + private String name; + + @Builder.Default + @Convert(converter = AdminRoleConverter.class) + @Comment("관리자 역할 목록") + @Column(name = "roles", nullable = false, columnDefinition = "json") + private List roles = new ArrayList<>(); + + @Enumerated(EnumType.STRING) + @Comment("관리자 상태") + @Column(name = "status", nullable = false) + @Builder.Default + private UserStatus status = UserStatus.ACTIVE; + + @Comment("리프레시 토큰") + @Column(name = "refresh_token", length = 512) + private String refreshToken; + + @Comment("마지막 로그인 시간") + @Column(name = "last_login_at") + private LocalDateTime lastLoginAt; + + public void updateRefreshToken(String refreshToken) { + Objects.requireNonNull(refreshToken, "refreshToken은 null이 될 수 없습니다."); + this.refreshToken = refreshToken; + } + + public void updateLastLoginAt(LocalDateTime lastLoginAt) { + this.lastLoginAt = lastLoginAt; + } + + public void deactivate() { + this.status = UserStatus.DELETED; + } + + public boolean isActive() { + return this.status == UserStatus.ACTIVE; + } + + public boolean hasRole(AdminRole role) { + return this.roles.contains(role); + } + + public void addRole(AdminRole role) { + if (!this.roles.contains(role)) { + this.roles.add(role); + } + } + + public void removeRole(AdminRole role) { + this.roles.remove(role); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/domain/AdminUserRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/user/domain/AdminUserRepository.java new file mode 100644 index 000000000..dfdcc3553 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/domain/AdminUserRepository.java @@ -0,0 +1,18 @@ +package app.bottlenote.user.domain; + +import java.util.Optional; + +public interface AdminUserRepository { + + AdminUser save(AdminUser adminUser); + + Optional findById(Long id); + + Optional findByEmail(String email); + + Optional findByRefreshToken(String refreshToken); + + boolean existsByEmail(String email); + + boolean existsActiveAdmin(); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/domain/RootAdmin.java b/bottlenote-mono/src/main/java/app/bottlenote/user/domain/RootAdmin.java index a35a19ad9..e91c3a4c0 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/domain/RootAdmin.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/domain/RootAdmin.java @@ -9,12 +9,16 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +@Builder @Getter @Entity(name = "root_admins") @Table(name = "root_admins") +@AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class RootAdmin { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/AdminSignupRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/AdminSignupRequest.java new file mode 100644 index 000000000..b421941b4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/AdminSignupRequest.java @@ -0,0 +1,18 @@ +package app.bottlenote.user.dto.request; + +import app.bottlenote.user.constant.AdminRole; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record AdminSignupRequest( + @NotBlank(message = "EMAIL_IS_REQUIRED") @Email(message = "USER_EMAIL_NOT_VALID") String email, + @NotBlank(message = "PASSWORD_IS_REQUIRED") + @Size(min = 8, max = 35, message = "USER_PASSWORD_NOT_VALID") + String password, + @NotBlank(message = "이름은 필수 입력값입니다.") + @Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하로 입력해주세요.") + String name, + @NotEmpty(message = "역할은 최소 1개 이상 선택해야 합니다.") List roles) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/AdminSignupResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/AdminSignupResponse.java new file mode 100644 index 000000000..45735b9ba --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/AdminSignupResponse.java @@ -0,0 +1,6 @@ +package app.bottlenote.user.dto.response; + +import app.bottlenote.user.constant.AdminRole; +import java.util.List; + +public record AdminSignupResponse(Long adminId, String email, String name, List roles) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/exception/UserExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/user/exception/UserExceptionCode.java index a043d0a40..5db49fcf1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/exception/UserExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/exception/UserExceptionCode.java @@ -28,6 +28,7 @@ public enum UserExceptionCode implements ExceptionCode { JSON_PARSING_EXCEPTION(HttpStatus.BAD_REQUEST, "JSON 처리 중 오류가 발생했습니다"), NOT_MATCH_GUEST_CODE(HttpStatus.BAD_REQUEST, "게스트 코드가 일치하지 않습니다."), INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), + LOGIN_FAILED(HttpStatus.BAD_REQUEST, "아이디 또는 비밀번호가 일치하지 않습니다."), TEMPORARY_LOGIN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "일시적인 로그인 오류입니다."), INVALID_APPLE_ID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 Apple ID 토큰입니다."), APPLE_ID_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "Apple ID 토큰이 만료되었습니다."), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/JpaAdminUserRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/JpaAdminUserRepository.java new file mode 100644 index 000000000..f4659bd08 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/JpaAdminUserRepository.java @@ -0,0 +1,22 @@ +package app.bottlenote.user.repository; + +import app.bottlenote.user.domain.AdminUser; +import app.bottlenote.user.domain.AdminUserRepository; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +@Repository +public interface JpaAdminUserRepository + extends AdminUserRepository, JpaRepository { + + Optional findByEmail(String email); + + Optional findByRefreshToken(String refreshToken); + + boolean existsByEmail(String email); + + @Query("SELECT COUNT(a) > 0 FROM admin_users a WHERE a.status = 'ACTIVE'") + boolean existsActiveAdmin(); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminAuthService.java b/bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminAuthService.java new file mode 100644 index 000000000..7423ff2f7 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminAuthService.java @@ -0,0 +1,163 @@ +package app.bottlenote.user.service; + +import static app.bottlenote.global.security.jwt.JwtTokenValidator.validateToken; +import static app.bottlenote.user.exception.UserExceptionCode.INVALID_REFRESH_TOKEN; +import static java.time.LocalDateTime.now; + +import app.bottlenote.global.security.jwt.AdminJwtAuthenticationManager; +import app.bottlenote.global.security.jwt.JwtTokenProvider; +import app.bottlenote.user.constant.AdminRole; +import app.bottlenote.user.domain.AdminUser; +import app.bottlenote.user.domain.AdminUserRepository; +import app.bottlenote.user.dto.request.AdminSignupRequest; +import app.bottlenote.user.dto.response.AdminSignupResponse; +import app.bottlenote.user.dto.response.TokenItem; +import app.bottlenote.user.exception.UserException; +import app.bottlenote.user.exception.UserExceptionCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminAuthService { + + private final AdminUserRepository adminUserRepository; + private final JwtTokenProvider tokenProvider; + private final AdminJwtAuthenticationManager authenticationManager; + private final BCryptPasswordEncoder passwordEncoder; + + @Transactional + public TokenItem login(String email, String encPassword) { + AdminUser admin = + adminUserRepository + .findByEmail(email) + .orElseThrow(() -> new UserException(UserExceptionCode.USER_NOT_FOUND)); + + if (!passwordEncoder.matches(encPassword, admin.getPassword())) { + throw new UserException(UserExceptionCode.LOGIN_FAILED); + } + + if (!admin.isActive()) { + throw new UserException(UserExceptionCode.USER_DELETED); + } + + TokenItem token = + tokenProvider.generateAdminToken(admin.getEmail(), admin.getRoles(), admin.getId()); + admin.updateRefreshToken(token.refreshToken()); + admin.updateLastLoginAt(now()); + + log.info("어드민 로그인: email={}, roles={}", admin.getEmail(), admin.getRoles()); + return token; + } + + @Transactional + public TokenItem refresh(String refreshToken) { + try { + if (!validateToken(refreshToken)) { + throw new UserException(INVALID_REFRESH_TOKEN); + } + } catch (UserException e) { + throw e; + } catch (Exception e) { + throw new UserException(INVALID_REFRESH_TOKEN); + } + + authenticationManager.getAuthentication(refreshToken); + + AdminUser admin = + adminUserRepository + .findByRefreshToken(refreshToken) + .orElseThrow(() -> new UserException(INVALID_REFRESH_TOKEN)); + + TokenItem newToken = + tokenProvider.generateAdminToken(admin.getEmail(), admin.getRoles(), admin.getId()); + admin.updateRefreshToken(newToken.refreshToken()); + + return newToken; + } + + @Transactional + public AdminSignupResponse signup(Long requesterId, AdminSignupRequest request) { + AdminUser requester = + adminUserRepository + .findById(requesterId) + .orElseThrow(() -> new UserException(UserExceptionCode.USER_NOT_FOUND)); + + if (!requester.isActive()) { + throw new UserException(UserExceptionCode.USER_DELETED); + } + + validateRoleAssignment(requester, request.roles()); + + if (adminUserRepository.existsByEmail(request.email())) { + throw new UserException(UserExceptionCode.USER_ALREADY_EXISTS); + } + + AdminUser newAdmin = + AdminUser.builder() + .email(request.email()) + .password(passwordEncoder.encode(request.password())) + .name(request.name()) + .roles(request.roles()) + .build(); + + AdminUser saved = adminUserRepository.save(newAdmin); + + log.info( + "어드민 계정 생성: adminId={}, email={}, roles={}, 생성자={}", + saved.getId(), + saved.getEmail(), + saved.getRoles(), + requesterId); + + return new AdminSignupResponse( + saved.getId(), saved.getEmail(), saved.getName(), saved.getRoles()); + } + + private void validateRoleAssignment(AdminUser requester, List requestedRoles) { + if (requestedRoles.contains(AdminRole.ROOT_ADMIN) && !requester.hasRole(AdminRole.ROOT_ADMIN)) { + throw new UserException(UserExceptionCode.ACCESS_DENIED); + } + } + + @Transactional + public void withdraw(Long adminId) { + AdminUser admin = + adminUserRepository + .findById(adminId) + .orElseThrow(() -> new UserException(UserExceptionCode.USER_NOT_FOUND)); + + admin.deactivate(); + log.info("어드민 탈퇴: adminId={}", adminId); + } + + @Transactional(readOnly = true) + public boolean rootAdminIsAlive() { + return adminUserRepository.existsActiveAdmin(); + } + + @Transactional + public boolean initRootAdmin(String email, String encodedPassword) { + if (adminUserRepository.existsByEmail(email)) { + log.info("이미 존재하는 이메일입니다. 초기화를 건너뜁니다. email={}", email); + return false; + } + + AdminUser rootAdmin = + AdminUser.builder() + .email(email) + .password(encodedPassword) + .name("ROOT_ADMIN") + .roles(List.of(AdminRole.ROOT_ADMIN)) + .build(); + + AdminUser saved = adminUserRepository.save(rootAdmin); + log.info("Root 어드민 계정 생성 완료. adminId={}", saved.getId()); + return true; + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/config/ModuleConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/config/ModuleConfig.java similarity index 100% rename from bottlenote-product-api/src/test/java/app/bottlenote/config/ModuleConfig.java rename to bottlenote-mono/src/test/java/app/bottlenote/config/ModuleConfig.java diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/config/TestConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/config/TestConfig.java similarity index 100% rename from bottlenote-product-api/src/test/java/app/bottlenote/config/TestConfig.java rename to bottlenote-mono/src/test/java/app/bottlenote/config/TestConfig.java diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/config/TestConfigProperties.java b/bottlenote-mono/src/test/java/app/bottlenote/config/TestConfigProperties.java similarity index 100% rename from bottlenote-product-api/src/test/java/app/bottlenote/config/TestConfigProperties.java rename to bottlenote-mono/src/test/java/app/bottlenote/config/TestConfigProperties.java diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/operation/utils/DataInitializer.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java similarity index 94% rename from bottlenote-product-api/src/test/java/app/bottlenote/operation/utils/DataInitializer.java rename to bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java index 22f45d170..0583e9b70 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/operation/utils/DataInitializer.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java @@ -10,13 +10,9 @@ import java.util.List; import java.util.Set; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; -import org.springframework.test.context.ActiveProfiles; @Slf4j -@Profile({"test", "batch"}) -@ActiveProfiles({"test", "batch"}) @Component @SuppressWarnings("unchecked") public class DataInitializer { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/operation/utils/TestAuthenticationSupport.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestAuthenticationSupport.java similarity index 100% rename from bottlenote-product-api/src/test/java/app/bottlenote/operation/utils/TestAuthenticationSupport.java rename to bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestAuthenticationSupport.java diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java similarity index 88% rename from bottlenote-product-api/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java rename to bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java index 2620dd167..0369dabf8 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java @@ -24,7 +24,10 @@ MySQLContainer mysqlContainer() { .withReuse(true) .withDatabaseName("bottlenote") .withUsername("root") - .withPassword("root"); + .withPassword("root") + .withInitScripts( + "storage/mysql/init/00-init-config-table.sql", + "storage/mysql/init/01-init-core-table.sql"); } /** Redis 컨테이너를 Spring Bean으로 등록합니다. @ServiceConnection이 자동으로 Redis 설정을 처리합니다. */ diff --git a/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/AdminUserTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/AdminUserTestFactory.java new file mode 100644 index 000000000..d288144ba --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/AdminUserTestFactory.java @@ -0,0 +1,120 @@ +package app.bottlenote.user.fixture; + +import app.bottlenote.user.constant.AdminRole; +import app.bottlenote.user.constant.UserStatus; +import app.bottlenote.user.domain.AdminUser; +import jakarta.persistence.EntityManager; +import java.security.SecureRandom; +import java.util.List; +import java.util.Random; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class AdminUserTestFactory { + + private final Random random = new SecureRandom(); + + @Autowired private EntityManager em; + + @Autowired private BCryptPasswordEncoder passwordEncoder; + + private String generateRandomSuffix() { + return String.valueOf(random.nextInt(10000)); + } + + /** 기본 ROOT_ADMIN 생성 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @NotNull + public AdminUser persistRootAdmin() { + return persistAdminUser( + "root-" + generateRandomSuffix() + "@bottlenote.com", + "password123", + "ROOT_ADMIN", + List.of(AdminRole.ROOT_ADMIN)); + } + + /** 특정 이메일로 ROOT_ADMIN 생성 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @NotNull + public AdminUser persistRootAdmin(@NotNull String email, @NotNull String rawPassword) { + return persistAdminUser(email, rawPassword, "ROOT_ADMIN", List.of(AdminRole.ROOT_ADMIN)); + } + + /** PARTNER 역할 어드민 생성 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @NotNull + public AdminUser persistPartnerAdmin() { + return persistAdminUser( + "partner-" + generateRandomSuffix() + "@bottlenote.com", + "password123", + "PARTNER_ADMIN", + List.of(AdminRole.PARTNER)); + } + + /** 다중 역할 어드민 생성 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @NotNull + public AdminUser persistMultiRoleAdmin(@NotNull List roles) { + return persistAdminUser( + "multi-" + generateRandomSuffix() + "@bottlenote.com", + "password123", + "MULTI_ROLE_ADMIN", + roles); + } + + /** 커스텀 어드민 생성 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @NotNull + public AdminUser persistAdminUser( + @NotNull String email, + @NotNull String rawPassword, + @NotNull String name, + @NotNull List roles) { + AdminUser adminUser = + AdminUser.builder() + .email(email) + .password(passwordEncoder.encode(rawPassword)) + .name(name) + .roles(roles) + .status(UserStatus.ACTIVE) + .build(); + em.persist(adminUser); + em.flush(); + return adminUser; + } + + /** 비활성 어드민 생성 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @NotNull + public AdminUser persistInactiveAdmin(@NotNull String email, @NotNull String rawPassword) { + AdminUser adminUser = + AdminUser.builder() + .email(email) + .password(passwordEncoder.encode(rawPassword)) + .name("INACTIVE_ADMIN") + .roles(List.of(AdminRole.PARTNER)) + .status(UserStatus.DELETED) + .build(); + em.persist(adminUser); + em.flush(); + return adminUser; + } + + /** 빌더 기반 어드민 생성 (비밀번호 자동 인코딩) */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @NotNull + public AdminUser persistAdminUser( + @NotNull AdminUser.AdminUserBuilder builder, @NotNull String rawPassword) { + AdminUser adminUser = builder.password(passwordEncoder.encode(rawPassword)).build(); + em.persist(adminUser); + em.flush(); + return adminUser; + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/user/service/AdminAuthServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/user/service/AdminAuthServiceTest.java new file mode 100644 index 000000000..f5ba5d1bb --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/user/service/AdminAuthServiceTest.java @@ -0,0 +1,5 @@ +package app.bottlenote.user.service; + +import static org.junit.jupiter.api.Assertions.*; + +class AdminAuthServiceTest {} diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index 7dea76edb..6d7de6e6a 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.1 +1.0.2 diff --git a/bottlenote-product-api/build.gradle b/bottlenote-product-api/build.gradle index 316e1cc3d..4d9527e56 100644 --- a/bottlenote-product-api/build.gradle +++ b/bottlenote-product-api/build.gradle @@ -14,6 +14,7 @@ configurations { dependencies { implementation project(':bottlenote-mono') + //noinspection GrUnresolvedAccess testImplementation project(':bottlenote-mono').sourceSets.test.output implementation project(':bottlenote-observability') implementation project(':bottlenote-batch') diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java b/bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java index 86088d8bc..50fe7678c 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java @@ -30,9 +30,7 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) public abstract class IntegrationTestSupport { - protected static final Logger log = LogManager.getLogger(IntegrationTestSupport.class); - @Autowired protected ObjectMapper mapper; @Autowired protected MockMvc mockMvc; @Autowired protected MockMvcTester mockMvcTester; diff --git a/bottlenote-product-api/src/test/resources/application-test.yml b/bottlenote-product-api/src/test/resources/application-test.yml index c80e35886..0865add11 100644 --- a/bottlenote-product-api/src/test/resources/application-test.yml +++ b/bottlenote-product-api/src/test/resources/application-test.yml @@ -25,11 +25,11 @@ spring: password: root sql: init: - mode: always - schema-locations: - - classpath:storage/mysql/init/00-init-config-table.sql - - classpath:storage/mysql/init/01-init-core-table.sql - # - classpath:storage/mysql/init/*.sql + # mode: always + # schema-locations: + # - classpath:storage/mysql/init/00-init-config-table.sql + # - classpath:storage/mysql/init/01-init-core-table.sql + # # - classpath:storage/mysql/init/*.sql batch: jdbc: diff --git a/build.gradle b/build.gradle index e06b337f7..63f0509f0 100644 --- a/build.gradle +++ b/build.gradle @@ -17,7 +17,7 @@ ext { // 전체 프로젝트 공통 설정 allprojects { group = 'bottlenote' - version = '0.0.1-SNAPSHOT' + version = '0.0.1' repositories { mavenCentral() } @@ -61,6 +61,7 @@ subprojects { // 공통 유틸리티 implementation libs.lombok annotationProcessor libs.lombok + testAnnotationProcessor libs.lombok implementation libs.commons.lang3 } @@ -95,7 +96,7 @@ subprojects { test { outputs.upToDateWhen { false } useJUnitPlatform { - excludeTags 'data-jpa-test', 'integration' + excludeTags 'data-jpa-test', 'integration', 'admin_integration' } testLogging { events "passed", "skipped", "failed" @@ -126,6 +127,12 @@ subprojects { } } + tasks.register('admin_integration_test', Test) { + useJUnitPlatform { + includeTags 'admin_integration' + } + } + // REST Docs 테스트 (app.docs 패키지) if (project.name in ['bottlenote-product-api', 'bottlenote-admin-api']) { tasks.register('restDocsTest', Test) { @@ -158,6 +165,13 @@ tasks.register('integration_test') { dependsOn subprojects.findAll { it.tasks.findByName('integration_test') }.collect { it.tasks.integration_test } } +// admin_integration +tasks.register('admin_integration_test') { + description = '어드민 모듈 통합 테스트' + group = 'verification' + dependsOn subprojects.findAll { it.tasks.findByName('admin_integration_test') }.collect { it.tasks.admin_integration_test } +} + tasks.register('unit_test') { description = '전체 모듈 단위 테스트 실행' group = 'verification' diff --git a/docs/admin-api.html b/docs/admin-api.html index aa66f41d8..1e20fdda3 100644 --- a/docs/admin-api.html +++ b/docs/admin-api.html @@ -442,7 +442,7 @@
-

2. Sample API

+

2. Auth API

-

2.1. Hello World API

+

2.1. 로그인

  • -

    Admin API 샘플 엔드포인트입니다.

    +

    관리자 계정으로 로그인하여 액세스 토큰과 리프레시 토큰을 발급받습니다.

  • +
+
+
+
+
POST /admin/api/v1/auth/login
+
+
+

요청 파라미터

+ +++++ + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

email

String

관리자 이메일

password

String

비밀번호

+
+
+
POST /auth/login HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 68
+Host: localhost:8080
+
+{
+  "email" : "admin@bottlenote.com",
+  "password" : "password123"
+}
+
+
+

응답 파라미터

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data.accessToken

String

액세스 토큰

data.refreshToken

String

리프레시 토큰

errors

Array

에러 목록

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 355
+
+{
+  "success" : true,
+  "code" : 200,
+  "data" : {
+    "accessToken" : "MdqzrkDjFbBVuhujMuqswwJsnXwREp9E",
+    "refreshToken" : "HlZlAhxicKGitIkwaZzDI6e4EaQZ4i5y"
+  },
+  "errors" : [ ],
+  "meta" : {
+    "serverVersion" : "1.0.0",
+    "serverEncoding" : "UTF-8",
+    "serverResponseTime" : "2025-12-24T00:30:43.091782",
+    "serverPathVersion" : "v1"
+  }
+}
+
+
+
+
+
+

2.2. 토큰 갱신

+
+
  • -

    Hello World 메시지를 반환합니다.

    +

    리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.

-
GET /admin/api/v1/hello
+
POST /admin/api/v1/auth/refresh
-

요청 파라미터

+

요청 파라미터

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

refreshToken

String

리프레시 토큰

+
+
+
POST /auth/refresh HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 47
+Host: localhost:8080
+
+{
+  "refreshToken" : "existing_refresh_token"
+}
+
+
+

응답 파라미터

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data.accessToken

String

새 액세스 토큰

data.refreshToken

String

새 리프레시 토큰

errors

Array

에러 목록

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 354
+
+{
+  "success" : true,
+  "code" : 200,
+  "data" : {
+    "accessToken" : "ZT2WjqFet4blwGshMHREVTHcK1MwbReI",
+    "refreshToken" : "bD4ttl3VrUacRIFKr7UplhxFypCXmRUr"
+  },
+  "errors" : [ ],
+  "meta" : {
+    "serverVersion" : "1.0.0",
+    "serverEncoding" : "UTF-8",
+    "serverResponseTime" : "2025-12-24T00:30:43.10809",
+    "serverPathVersion" : "v1"
+  }
+}
+
+
+
+
+
+

2.3. 탈퇴

+
+
    +
  • +

    인증된 관리자 계정을 탈퇴 처리합니다.

    +
  • +
+
-
$ curl 'http://localhost:8080/admin/api/v1/hello' -i -X GET
+
DELETE /admin/api/v1/auth/withdraw
+

요청 헤더

+ ++++ + + + + + + + + + + + + +
NameDescription

Authorization

Bearer 액세스 토큰

-
GET /admin/api/v1/hello HTTP/1.1
+
DELETE /auth/withdraw HTTP/1.1
+Authorization: Bearer test_access_token
 Host: localhost:8080
-

응답 파라미터

+

응답 파라미터

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data.message

String

처리 결과 메시지

errors

Array

에러 목록

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 291
+
+{
+  "success" : true,
+  "code" : 200,
+  "data" : {
+    "message" : "탈퇴 처리되었습니다."
+  },
+  "errors" : [ ],
+  "meta" : {
+    "serverVersion" : "1.0.0",
+    "serverEncoding" : "UTF-8",
+    "serverResponseTime" : "2025-12-24T00:30:43.061124",
+    "serverPathVersion" : "v1"
+  }
+}
+
+
+
+
+
+
+
+

3. Alcohol API

+
+
+

3.1. 술(Alcohol) 목록 조회

+
+
    +
  • +

    관리자용 술 목록 조회 API입니다.

    +
  • +
  • +

    키워드 검색, 카테고리 필터, 정렬, 페이징을 지원합니다.

    +
  • +
+
+
+
+
GET /admin/api/v1/alcohols
+
+
+

요청 파라미터

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

keyword

검색어 (한글/영문 이름 검색)

category

카테고리 그룹 필터 (예: SINGLE_MALT, BLEND 등)

regionId

지역 ID 필터

sortType

정렬 기준 (KOR_NAME, ENG_NAME, KOR_CATEGORY, ENG_CATEGORY / 기본값: KOR_NAME)

sortOrder

정렬 방향 (기본값: ASC)

page

페이지 번호 (기본값: 0)

size

페이지 크기 (기본값: 20)

+
+
+
$ curl 'http://localhost:8080/alcohols?keyword=%EA%B8%80%EB%A0%8C&category=SINGLE_MALT&regionId=1&sortType=KOR_NAME&sortOrder=ASC&page=0&size=20' -i -X GET
+
+
+
+
+
GET /alcohols?keyword=%EA%B8%80%EB%A0%8C&category=SINGLE_MALT&regionId=1&sortType=KOR_NAME&sortOrder=ASC&page=0&size=20 HTTP/1.1
+Host: localhost:8080
+
+
+

응답 파라미터

@@ -752,7 +1143,7 @@

응답 파라미터

- + @@ -761,13 +1152,48 @@

응답 파라미터

- - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + @@ -780,24 +1206,29 @@

응답 파라미터

- - - + + + - - - + + + - - - + + + - - - + + + + + + + +

success

Boolean

성공 여부

응답 성공 여부

code

data

Object

응답 데이터

Array

술 목록 데이터

data.message

data[].alcoholId

Number

술 ID

data[].korName

String

술 한글 이름

data[].engName

String

술 영문 이름

data[].korCategoryName

String

카테고리 한글명

data[].engCategoryName

String

카테고리 영문명

data[].imageUrl

String

술 이미지 URL

data[].createdAt

String

메시지

생성일시

data[].modifiedAt

String

수정일시

errors

메타 정보

meta.serverVersion

String

서버 버전

meta.page

Number

현재 페이지 번호

meta.serverEncoding

String

인코딩

meta.size

Number

페이지 크기

meta.serverResponseTime

String

응답 시간

meta.totalElements

Number

전체 요소 수

meta.serverPathVersion

String

경로 버전

meta.totalPages

Number

전체 페이지 수

meta.hasNext

Boolean

다음 페이지 존재 여부

@@ -805,9 +1236,43 @@

응답 파라미터

HTTP/1.1 200 OK
 Content-Type: application/json
-Content-Length: 206
+Content-Length: 969
 
-{"success":true,"code":200,"data":{"message":"Hello World!"},"errors":[],"meta":{"serverVersion":"1.0.0","serverEncoding":"UTF-8","serverResponseTime":"2025-10-08T01:41:34.556368","serverPathVersion":"v1"}}
+{ + "success" : true, + "code" : 200, + "data" : [ { + "alcoholId" : 1, + "korName" : "테스트 위스키 1", + "engName" : "Test Whisky 1", + "korCategoryName" : "싱글몰트", + "engCategoryName" : "Single Malt", + "imageUrl" : "https://example.com/image.jpg", + "createdAt" : "2024-01-01T00:00:00", + "modifiedAt" : "2024-06-01T00:00:00" + }, { + "alcoholId" : 2, + "korName" : "테스트 위스키 2", + "engName" : "Test Whisky 2", + "korCategoryName" : "싱글몰트", + "engCategoryName" : "Single Malt", + "imageUrl" : "https://example.com/image.jpg", + "createdAt" : "2024-02-01T00:00:00", + "modifiedAt" : "2024-07-01T00:00:00" + } ], + "errors" : [ ], + "meta" : { + "page" : 0, + "size" : 20, + "totalElements" : 2, + "totalPages" : 1, + "hasNext" : false, + "serverVersion" : "1.0.0", + "serverEncoding" : "UTF-8", + "serverResponseTime" : "2025-12-24T00:30:42.412696", + "serverPathVersion" : "v1" + } +}
@@ -816,8 +1281,8 @@

응답 파라미터

diff --git a/git.environment-variables b/git.environment-variables index 152fc0a9a..b980a67ea 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 152fc0a9a747df9406062670da9eeace01325760 +Subproject commit b980a67ea47a960cdf9f7bf91ae104c7c5c1ffa9