From a832bc81cba66305561aec778b1fbb8e137c7972 Mon Sep 17 00:00:00 2001 From: Jaeyoon Lee <63213487+sosow0212@users.noreply.github.com> Date: Thu, 1 Aug 2024 21:56:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=BC=88?= =?UTF-8?q?=EB=8C=80=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: yml 주입방식 변경 (테스트 용이와 간편함을 위해) 및 불필요한 속성 Config 이전 * refactor: YamlPropertySourceFactory 위치 변경 * refactor: "/api" 프리픽스 적용 및 의존성 불필요한 주석 제거 * refactor: 패키지명 변경 * feat: 간단한 로그인 뼈대 구현 및 테스트 작성 * chore: TestFixtures 모듈 사용을 위한 종속성 추가 * feat: 회원가입 뼈대 코드 및 테스트 작성 * refactor: 패키지 이동 * refactor: CustomException을 Enumd으로 관리해서 중복 코드 줄이도록 변경 --- backend/build.gradle | 2 + backend/pcloud-api/build.gradle | 4 + backend/pcloud-api/docs/asciidoc/index.adoc | 10 ++ backend/pcloud-api/docs/asciidoc/member.adoc | 30 +++++ .../com/api/global/config/AuthConfig.java | 51 ++++++++ .../api/{ => global}/config/WebMvcConfig.java | 4 +- .../config/filter/CorsCustomFilter.java | 2 +- .../auth/LoginValidCheckerInterceptor.java | 34 ++++++ .../ParseMemberIdFromTokenInterceptor.java | 29 +++++ .../auth/PathMatcherInterceptor.java | 38 ++++++ .../auth/support/AuthenticationContext.java | 34 ++++++ .../auth/support/AuthenticationExtractor.java | 35 ++++++ .../interceptor/auth/support/HttpMethod.java | 20 ++++ .../auth/support/PathContainer.java | 42 +++++++ .../interceptor/auth/support/PathRequest.java | 21 ++++ .../moduleutils}/ComponentScanConfig.java | 4 +- .../moduleutils/PropertySourceScanConfig.java | 16 +++ .../YamlPropertySourceFactory.java | 16 +++ .../config/resolver/AuthArgumentResolver.java | 42 +++++++ .../exception/ControllerExceptionsAdvice.java | 36 ++++++ .../exception/response/ExceptionResponse.java | 7 ++ .../api/member/application/MemberService.java | 53 ++++++++ .../application/request/LoginRequest.java | 7 ++ .../application/request/SignupRequest.java | 7 ++ .../infrastructure/MemberRepositoryImpl.java | 36 ++++++ .../member/presentation/MemberController.java | 39 ++++++ .../presentation/response/TokenResponse.java | 6 + .../src/main/resources/application.yml | 9 +- .../java/com/api/PCloudApiServerTest.java | 2 +- .../support/AuthenticationContextTest.java | 59 +++++++++ .../support/AuthenticationExtractorTest.java | 51 ++++++++ .../auth/support/HttpMethodTest.java | 25 ++++ .../LoginValidCheckerInterceptorTest.java | 40 +++++++ .../auth/support/PathContainerTest.java | 67 +++++++++++ .../auth/support/PathRequestTest.java | 30 +++++ .../com/api/helper/IntegrationHelper.java | 2 - .../com/api/helper/MockBeanInjection.java | 15 ++- .../member/application/MemberServiceTest.java | 113 ++++++++++++++++++ .../MemberControllerAcceptanceFixture.java | 51 ++++++++ .../MemberControllerAcceptanceTest.java | 35 ++++++ .../MemberControllerWebMvcTest.java | 80 +++++++++++++ .../src/test/resources/application.yml | 5 +- backend/pcloud-common/build.gradle | 5 +- .../com/common/annotation/AuthMember.java | 11 ++ .../com/common/auth/JwtTokenProvider.java | 98 +++++++++++++++ .../java/com/common/auth/TokenProvider.java | 8 ++ .../com/common/exception/AuthException.java | 16 +++ .../common/exception/AuthExceptionType.java | 29 +++++ .../com/common/exception/CustomException.java | 10 ++ .../common/exception/CustomExceptionType.java | 10 ++ ...ommon.yml => application-common-local.yml} | 0 .../resources/application-common-prod.yml | 8 ++ .../resources/application-common-test.yml | 7 ++ .../java/auth/FakeTokenProvider.java | 16 +++ .../domain/domains/member/domain/Member.java | 43 +++++++ .../member/domain/MemberRepository.java | 14 +++ .../member/exception/MemberException.java | 19 +++ .../member/exception/MemberExceptionType.java | 30 +++++ .../infrastructure/MemberJpaRepository.java | 17 +++ .../resources/application-domain-local.yml | 19 +++ .../resources/application-domain-prod.yml | 14 +++ .../resources/application-domain-test.yml | 17 +++ .../src/main/resources/application-domain.yml | 42 ------- .../domains/member/domain/MemberTest.java | 26 ++++ .../member/fixture/FakeMemberRepository.java | 46 +++++++ .../java/member/fixture/MemberFixture.java | 13 ++ backend/pcloud-infrastructure/build.gradle | 2 + ...l => application-infrastructure-local.yml} | 0 .../application-infrastructure-prod.yml | 0 .../application-infrastructure-test.yml | 0 70 files changed, 1665 insertions(+), 64 deletions(-) create mode 100644 backend/pcloud-api/docs/asciidoc/index.adoc create mode 100644 backend/pcloud-api/docs/asciidoc/member.adoc create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/AuthConfig.java rename backend/pcloud-api/src/main/java/com/api/{ => global}/config/WebMvcConfig.java (89%) rename backend/pcloud-api/src/main/java/com/api/{ => global}/config/filter/CorsCustomFilter.java (96%) create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/LoginValidCheckerInterceptor.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/ParseMemberIdFromTokenInterceptor.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/PathMatcherInterceptor.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/AuthenticationContext.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/AuthenticationExtractor.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/HttpMethod.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/PathContainer.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/PathRequest.java rename backend/pcloud-api/src/main/java/com/api/{config => global/config/moduleutils}/ComponentScanConfig.java (59%) create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/PropertySourceScanConfig.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/YamlPropertySourceFactory.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/config/resolver/AuthArgumentResolver.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/exception/ControllerExceptionsAdvice.java create mode 100644 backend/pcloud-api/src/main/java/com/api/global/exception/response/ExceptionResponse.java create mode 100644 backend/pcloud-api/src/main/java/com/api/member/application/MemberService.java create mode 100644 backend/pcloud-api/src/main/java/com/api/member/application/request/LoginRequest.java create mode 100644 backend/pcloud-api/src/main/java/com/api/member/application/request/SignupRequest.java create mode 100644 backend/pcloud-api/src/main/java/com/api/member/infrastructure/MemberRepositoryImpl.java create mode 100644 backend/pcloud-api/src/main/java/com/api/member/presentation/MemberController.java create mode 100644 backend/pcloud-api/src/main/java/com/api/member/presentation/response/TokenResponse.java create mode 100644 backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/AuthenticationContextTest.java create mode 100644 backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/AuthenticationExtractorTest.java create mode 100644 backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/HttpMethodTest.java create mode 100644 backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/LoginValidCheckerInterceptorTest.java create mode 100644 backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/PathContainerTest.java create mode 100644 backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/PathRequestTest.java create mode 100644 backend/pcloud-api/src/test/java/com/api/member/application/MemberServiceTest.java create mode 100644 backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerAcceptanceFixture.java create mode 100644 backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerAcceptanceTest.java create mode 100644 backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerWebMvcTest.java create mode 100644 backend/pcloud-common/src/main/java/com/common/annotation/AuthMember.java create mode 100644 backend/pcloud-common/src/main/java/com/common/auth/JwtTokenProvider.java create mode 100644 backend/pcloud-common/src/main/java/com/common/auth/TokenProvider.java create mode 100644 backend/pcloud-common/src/main/java/com/common/exception/AuthException.java create mode 100644 backend/pcloud-common/src/main/java/com/common/exception/AuthExceptionType.java create mode 100644 backend/pcloud-common/src/main/java/com/common/exception/CustomException.java create mode 100644 backend/pcloud-common/src/main/java/com/common/exception/CustomExceptionType.java rename backend/pcloud-common/src/main/resources/{application-common.yml => application-common-local.yml} (100%) create mode 100644 backend/pcloud-common/src/main/resources/application-common-prod.yml create mode 100644 backend/pcloud-common/src/main/resources/application-common-test.yml create mode 100644 backend/pcloud-common/src/testFixtures/java/auth/FakeTokenProvider.java create mode 100644 backend/pcloud-domain/src/main/java/com/domain/domains/member/domain/Member.java create mode 100644 backend/pcloud-domain/src/main/java/com/domain/domains/member/domain/MemberRepository.java create mode 100644 backend/pcloud-domain/src/main/java/com/domain/domains/member/exception/MemberException.java create mode 100644 backend/pcloud-domain/src/main/java/com/domain/domains/member/exception/MemberExceptionType.java create mode 100644 backend/pcloud-domain/src/main/java/com/domain/domains/member/infrastructure/MemberJpaRepository.java create mode 100644 backend/pcloud-domain/src/main/resources/application-domain-local.yml create mode 100644 backend/pcloud-domain/src/main/resources/application-domain-prod.yml create mode 100644 backend/pcloud-domain/src/main/resources/application-domain-test.yml delete mode 100644 backend/pcloud-domain/src/main/resources/application-domain.yml create mode 100644 backend/pcloud-domain/src/test/java/com/domain/domains/member/domain/MemberTest.java create mode 100644 backend/pcloud-domain/src/testFixtures/java/member/fixture/FakeMemberRepository.java create mode 100644 backend/pcloud-domain/src/testFixtures/java/member/fixture/MemberFixture.java rename backend/pcloud-infrastructure/src/main/resources/{application-infrastructure.yml => application-infrastructure-local.yml} (100%) create mode 100644 backend/pcloud-infrastructure/src/main/resources/application-infrastructure-prod.yml create mode 100644 backend/pcloud-infrastructure/src/main/resources/application-infrastructure-test.yml diff --git a/backend/build.gradle b/backend/build.gradle index 89963327..070a2b56 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.springframework.boot' version '3.3.2' id 'io.spring.dependency-management' version '1.1.6' id "org.asciidoctor.jvm.convert" version "4.0.2" + id 'java-test-fixtures' } bootJar.enabled = false @@ -21,6 +22,7 @@ subprojects { apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' apply plugin: 'org.asciidoctor.jvm.convert' + apply plugin: 'java-test-fixtures' // fixtures-module 사용 configurations { compileOnly { diff --git a/backend/pcloud-api/build.gradle b/backend/pcloud-api/build.gradle index 332b1f5a..7517517c 100644 --- a/backend/pcloud-api/build.gradle +++ b/backend/pcloud-api/build.gradle @@ -25,6 +25,10 @@ dependencies { implementation project(":pcloud-domain") implementation project(":pcloud-infrastructure") + // testFixtures import + testImplementation(testFixtures(project(":pcloud-domain"))) + testImplementation(testFixtures(project(":pcloud-common"))) + // spring web implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/backend/pcloud-api/docs/asciidoc/index.adoc b/backend/pcloud-api/docs/asciidoc/index.adoc new file mode 100644 index 00000000..76cebee6 --- /dev/null +++ b/backend/pcloud-api/docs/asciidoc/index.adoc @@ -0,0 +1,10 @@ +ifndef::snippets[] +:snippets: ./build/generated-snippets +endif::[] += e-market +:doctype: book +:toc: left +:source-highlighter: highlightjs +:sectlinks: + +include::member.adoc[] diff --git a/backend/pcloud-api/docs/asciidoc/member.adoc b/backend/pcloud-api/docs/asciidoc/member.adoc new file mode 100644 index 00000000..2bbbda25 --- /dev/null +++ b/backend/pcloud-api/docs/asciidoc/member.adoc @@ -0,0 +1,30 @@ += Auth API 문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 + +== 회원가입을 진행한다 (POST /api/signup) + +=== Request + +include::{snippets}/member-controller-test/do_signup/request-fields.adoc[] +include::{snippets}/member-controller-test/do_signup/http-request.adoc[] + +=== Response + +include::{snippets}/member-controller-test/do_signup/response-fields.adoc[] +include::{snippets}/member-controller-test/do_signup/http-response.adoc[] + +== 로그인을 진행한다 (POST /api/login) + +=== Request + +include::{snippets}/member-controller-test/do_login/request-fields.adoc[] +include::{snippets}/member-controller-test/do_login/http-request.adoc[] + +=== Response + +include::{snippets}/member-controller-test/do_login/response-fields.adoc[] +include::{snippets}/member-controller-test/do_login/http-response.adoc[] diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/AuthConfig.java b/backend/pcloud-api/src/main/java/com/api/global/config/AuthConfig.java new file mode 100644 index 00000000..a8716195 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/AuthConfig.java @@ -0,0 +1,51 @@ +package com.api.global.config; + +import com.api.global.config.interceptor.auth.LoginValidCheckerInterceptor; +import com.api.global.config.interceptor.auth.ParseMemberIdFromTokenInterceptor; +import com.api.global.config.interceptor.auth.PathMatcherInterceptor; +import com.api.global.config.resolver.AuthArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +import static com.api.global.config.interceptor.auth.support.HttpMethod.DELETE; +import static com.api.global.config.interceptor.auth.support.HttpMethod.GET; +import static com.api.global.config.interceptor.auth.support.HttpMethod.OPTIONS; +import static com.api.global.config.interceptor.auth.support.HttpMethod.PATCH; +import static com.api.global.config.interceptor.auth.support.HttpMethod.POST; + +@RequiredArgsConstructor +@Configuration +public class AuthConfig implements WebMvcConfigurer { + + private final AuthArgumentResolver authArgumentResolver; + private final ParseMemberIdFromTokenInterceptor parseMemberIdFromTokenInterceptor; + private final LoginValidCheckerInterceptor loginValidCheckerInterceptor; + + @Override + public void addInterceptors(final InterceptorRegistry registry) { + registry.addInterceptor(parseMemberIdFromTokenInterceptor()); + registry.addInterceptor(loginValidCheckerInterceptor()); + } + + private HandlerInterceptor parseMemberIdFromTokenInterceptor() { + return new PathMatcherInterceptor(parseMemberIdFromTokenInterceptor) + .excludePathPattern("/**", OPTIONS); + } + + private HandlerInterceptor loginValidCheckerInterceptor() { + return new PathMatcherInterceptor(loginValidCheckerInterceptor) + .excludePathPattern("/**", OPTIONS) + .addPathPatterns("/members/test", GET, POST, PATCH, DELETE); + } + + @Override + public void addArgumentResolvers(final List resolvers) { + resolvers.add(authArgumentResolver); + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/config/WebMvcConfig.java b/backend/pcloud-api/src/main/java/com/api/global/config/WebMvcConfig.java similarity index 89% rename from backend/pcloud-api/src/main/java/com/api/config/WebMvcConfig.java rename to backend/pcloud-api/src/main/java/com/api/global/config/WebMvcConfig.java index c5ea2684..c272ec76 100644 --- a/backend/pcloud-api/src/main/java/com/api/config/WebMvcConfig.java +++ b/backend/pcloud-api/src/main/java/com/api/global/config/WebMvcConfig.java @@ -1,6 +1,6 @@ -package com.api.config; +package com.api.global.config; -import com.api.config.filter.CorsCustomFilter; +import com.api.global.config.filter.CorsCustomFilter; import jakarta.servlet.Filter; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; diff --git a/backend/pcloud-api/src/main/java/com/api/config/filter/CorsCustomFilter.java b/backend/pcloud-api/src/main/java/com/api/global/config/filter/CorsCustomFilter.java similarity index 96% rename from backend/pcloud-api/src/main/java/com/api/config/filter/CorsCustomFilter.java rename to backend/pcloud-api/src/main/java/com/api/global/config/filter/CorsCustomFilter.java index 8627acdf..461c2be8 100644 --- a/backend/pcloud-api/src/main/java/com/api/config/filter/CorsCustomFilter.java +++ b/backend/pcloud-api/src/main/java/com/api/global/config/filter/CorsCustomFilter.java @@ -1,4 +1,4 @@ -package com.api.config.filter; +package com.api.global.config.filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/LoginValidCheckerInterceptor.java b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/LoginValidCheckerInterceptor.java new file mode 100644 index 00000000..cae83265 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/LoginValidCheckerInterceptor.java @@ -0,0 +1,34 @@ +package com.api.global.config.interceptor.auth; + +import com.api.global.config.interceptor.auth.support.AuthenticationContext; +import com.api.global.config.interceptor.auth.support.AuthenticationExtractor; +import com.common.auth.TokenProvider; +import com.common.exception.AuthException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import static com.common.exception.AuthExceptionType.SIGNATURE_INVALID_EXCEPTION; + +@RequiredArgsConstructor +@Component +public class LoginValidCheckerInterceptor implements HandlerInterceptor { + + private final TokenProvider tokenProvider; + private final AuthenticationContext authenticationContext; + + @Override + public boolean preHandle(final HttpServletRequest request, + final HttpServletResponse response, + final Object handler) throws Exception { + String token = AuthenticationExtractor.extract(request) + .orElseThrow(() -> new AuthException(SIGNATURE_INVALID_EXCEPTION)); + + Long memberId = tokenProvider.extract(token); + authenticationContext.setAuthentication(memberId); + + return true; + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/ParseMemberIdFromTokenInterceptor.java b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/ParseMemberIdFromTokenInterceptor.java new file mode 100644 index 00000000..fab907af --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/ParseMemberIdFromTokenInterceptor.java @@ -0,0 +1,29 @@ +package com.api.global.config.interceptor.auth; + +import com.api.global.config.interceptor.auth.support.AuthenticationContext; +import com.api.global.config.interceptor.auth.support.AuthenticationExtractor; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@RequiredArgsConstructor +@Component +public class ParseMemberIdFromTokenInterceptor implements HandlerInterceptor { + + private final LoginValidCheckerInterceptor loginValidCheckerInterceptor; + private final AuthenticationContext authenticationContext; + + @Override + public boolean preHandle(final HttpServletRequest request, + final HttpServletResponse response, + final Object handler) throws Exception { + if (AuthenticationExtractor.extract(request).isEmpty()) { + authenticationContext.setAnonymous(); + return true; + } + + return loginValidCheckerInterceptor.preHandle(request, response, handler); + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/PathMatcherInterceptor.java b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/PathMatcherInterceptor.java new file mode 100644 index 00000000..823576cb --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/PathMatcherInterceptor.java @@ -0,0 +1,38 @@ +package com.api.global.config.interceptor.auth; + +import com.api.global.config.interceptor.auth.support.HttpMethod; +import com.api.global.config.interceptor.auth.support.PathContainer; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.HandlerInterceptor; + +public class PathMatcherInterceptor implements HandlerInterceptor { + + private final HandlerInterceptor handlerInterceptor; + private final PathContainer pathContainer; + + public PathMatcherInterceptor(final HandlerInterceptor handlerInterceptor) { + this.handlerInterceptor = handlerInterceptor; + this.pathContainer = new PathContainer(); + } + + @Override + public boolean preHandle(final HttpServletRequest request, + final HttpServletResponse response, + final Object handler) throws Exception { + if (pathContainer.isNotIncludedPath(request.getServletPath(), request.getMethod())) { + return true; + } + return handlerInterceptor.preHandle(request, response, handler); + } + + public PathMatcherInterceptor addPathPatterns(final String pathPattern, final HttpMethod... httpMethod) { + pathContainer.addIncludePatterns(pathPattern, httpMethod); + return this; + } + + public PathMatcherInterceptor excludePathPattern(final String pathPattern, final HttpMethod... pathMethod) { + pathContainer.addExcludePatterns(pathPattern, pathMethod); + return this; + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/AuthenticationContext.java b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/AuthenticationContext.java new file mode 100644 index 00000000..c3f17a9e --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/AuthenticationContext.java @@ -0,0 +1,34 @@ +package com.api.global.config.interceptor.auth.support; + +import com.common.exception.AuthException; +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +import java.util.Objects; + +import static com.common.exception.AuthExceptionType.LOGIN_INVALID_EXCEPTION; + +@RequestScope +@Component +public class AuthenticationContext { + + private static final Long ANONYMOUS_MEMBER = -1L; + + private Long memberId; + + public void setAuthentication(final Long memberId) { + this.memberId = memberId; + } + + public Long getPrincipal() { + if (Objects.isNull(this.memberId)) { + throw new AuthException(LOGIN_INVALID_EXCEPTION); + } + + return memberId; + } + + public void setAnonymous() { + this.memberId = ANONYMOUS_MEMBER; + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/AuthenticationExtractor.java b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/AuthenticationExtractor.java new file mode 100644 index 00000000..9116bf01 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/AuthenticationExtractor.java @@ -0,0 +1,35 @@ +package com.api.global.config.interceptor.auth.support; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +public class AuthenticationExtractor { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER = "Bearer"; + private static final String HEADER_SPLIT_DELIMITER = " "; + private static final int TOKEN_TYPE_INDEX = 0; + private static final int TOKEN_VALUE_INDEX = 1; + private static final int VALID_HEADER_SPLIT_LENGTH = 2; + + public static Optional extract(final HttpServletRequest request) { + String header = request.getHeader(AUTHORIZATION_HEADER); + + if (!StringUtils.hasText(header)) { + return Optional.empty(); + } + + return extractFromHeader(header.split(HEADER_SPLIT_DELIMITER)); + } + + public static Optional extractFromHeader(final String[] headerParts) { + if (headerParts.length == VALID_HEADER_SPLIT_LENGTH && + headerParts[TOKEN_TYPE_INDEX].equals(BEARER)) { + return Optional.ofNullable(headerParts[TOKEN_VALUE_INDEX]); + } + + return Optional.empty(); + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/HttpMethod.java b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/HttpMethod.java new file mode 100644 index 00000000..52443146 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/HttpMethod.java @@ -0,0 +1,20 @@ +package com.api.global.config.interceptor.auth.support; + +public enum HttpMethod { + + GET, + POST, + PUT, + PATCH, + DELETE, + OPTIONS, + HEAD, + TRACE, + CONNECT, + ANY; + + public boolean matches(final String pathMethod) { + return this == ANY || + this.name().equalsIgnoreCase(pathMethod); + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/PathContainer.java b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/PathContainer.java new file mode 100644 index 00000000..19d73502 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/PathContainer.java @@ -0,0 +1,42 @@ +package com.api.global.config.interceptor.auth.support; + +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; + +import java.util.ArrayList; +import java.util.List; + +public class PathContainer { + + private final PathMatcher pathMatcher; + private final List includePatterns; + private final List excludePatterns; + + public PathContainer() { + this.pathMatcher = new AntPathMatcher(); + this.includePatterns = new ArrayList<>(); + this.excludePatterns = new ArrayList<>(); + } + + public boolean isNotIncludedPath(final String targetPath, final String pathMethod) { + boolean isExcludePattern = excludePatterns.stream() + .anyMatch(pathPattern -> pathPattern.matches(pathMatcher, targetPath, pathMethod)); + + boolean isNotIncludePattern = includePatterns.stream() + .noneMatch(pathPattern -> pathPattern.matches(pathMatcher, targetPath, pathMethod)); + + return isExcludePattern || isNotIncludePattern; + } + + public void addIncludePatterns(final String path, final HttpMethod... method) { + for (HttpMethod httpMethod : method) { + includePatterns.add(new PathRequest(path, httpMethod)); + } + } + + public void addExcludePatterns(final String path, final HttpMethod... method) { + for (HttpMethod httpMethod : method) { + excludePatterns.add(new PathRequest(path, httpMethod)); + } + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/PathRequest.java b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/PathRequest.java new file mode 100644 index 00000000..a26bd3de --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/interceptor/auth/support/PathRequest.java @@ -0,0 +1,21 @@ +package com.api.global.config.interceptor.auth.support; + +import org.springframework.util.PathMatcher; + +public class PathRequest { + + private final String path; + private final HttpMethod httpMethod; + + public PathRequest(final String path, final HttpMethod httpMethod) { + this.path = path; + this.httpMethod = httpMethod; + } + + public boolean matches(final PathMatcher pathMatcher, + final String targetPath, + final String pathMethod) { + return pathMatcher.match(path, targetPath) && + httpMethod.matches(pathMethod); + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/config/ComponentScanConfig.java b/backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/ComponentScanConfig.java similarity index 59% rename from backend/pcloud-api/src/main/java/com/api/config/ComponentScanConfig.java rename to backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/ComponentScanConfig.java index 267db9fd..930457c1 100644 --- a/backend/pcloud-api/src/main/java/com/api/config/ComponentScanConfig.java +++ b/backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/ComponentScanConfig.java @@ -1,9 +1,9 @@ -package com.api.config; +package com.api.global.config.moduleutils; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; @Configuration -@ComponentScan(value = {"com.common", "com.domain"}) +@ComponentScan(value = {"com.common", "com.domain", "com.infrastructure"}) public class ComponentScanConfig { } diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/PropertySourceScanConfig.java b/backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/PropertySourceScanConfig.java new file mode 100644 index 00000000..2f98dd11 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/PropertySourceScanConfig.java @@ -0,0 +1,16 @@ +package com.api.global.config.moduleutils; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@PropertySource( + value = { + "classpath:application-common-${spring.profiles.active}.yml", + "classpath:application-domain-${spring.profiles.active}.yml", + "classpath:application-infrastructure-${spring.profiles.active}.yml" + }, + factory = YamlPropertySourceFactory.class +) +@Configuration +public class PropertySourceScanConfig { +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/YamlPropertySourceFactory.java b/backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/YamlPropertySourceFactory.java new file mode 100644 index 00000000..da3d8ef3 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/moduleutils/YamlPropertySourceFactory.java @@ -0,0 +1,16 @@ +package com.api.global.config.moduleutils; + +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.io.support.DefaultPropertySourceFactory; +import org.springframework.core.io.support.EncodedResource; + +public class YamlPropertySourceFactory extends DefaultPropertySourceFactory { + + @Override + public org.springframework.core.env.PropertySource createPropertySource(final String name, final EncodedResource resource) { + YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); + factory.setResources(resource.getResource()); + return new PropertiesPropertySource(resource.getResource().getFilename(), factory.getObject()); + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/config/resolver/AuthArgumentResolver.java b/backend/pcloud-api/src/main/java/com/api/global/config/resolver/AuthArgumentResolver.java new file mode 100644 index 00000000..56801876 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/config/resolver/AuthArgumentResolver.java @@ -0,0 +1,42 @@ +package com.api.global.config.resolver; + +import com.api.global.config.interceptor.auth.support.AuthenticationContext; +import com.common.annotation.AuthMember; +import com.common.exception.AuthException; +import com.common.exception.AuthExceptionType; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + private static final int ANONYMOUS = -1; + + private final AuthenticationContext authenticationContext; + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class) && + parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument(final MethodParameter parameter, + final ModelAndViewContainer mavContainer, + final NativeWebRequest webRequest, + final WebDataBinderFactory binderFactory) throws Exception { + Long memberId = authenticationContext.getPrincipal(); + + if (memberId == ANONYMOUS) { + throw new AuthException(AuthExceptionType.LOGIN_INVALID_EXCEPTION); + } + + return memberId; + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/exception/ControllerExceptionsAdvice.java b/backend/pcloud-api/src/main/java/com/api/global/exception/ControllerExceptionsAdvice.java new file mode 100644 index 00000000..ceadd6bc --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/exception/ControllerExceptionsAdvice.java @@ -0,0 +1,36 @@ +package com.api.global.exception; + +import com.api.global.exception.response.ExceptionResponse; +import com.common.exception.CustomException; +import com.common.exception.CustomExceptionType; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@Slf4j +@RequiredArgsConstructor +@RestControllerAdvice +public class ControllerExceptionsAdvice extends ResponseEntityExceptionHandler { + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(final HttpServletRequest request, final Exception exception) { + log.error("예상하지 못한 예외가 발생했습니다. uri: {} {}, ", request.getMethod(), request.getRequestURI(), exception); + + return ResponseEntity.internalServerError() + .body(new ExceptionResponse("INTERNAL_EXCEPTION", "알 수 없는 오류가 발생했습니다.")); + } + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleException(final HttpServletRequest request, final CustomException exception) { + CustomExceptionType type = exception.getExceptionType(); + log.info("잘못된 요청이 들어왔습니다. uri: {} {}, 내용: {}", request.getMethod(), request.getRequestURI(), type.getMessage()); + + return ResponseEntity.status(HttpStatusCode.valueOf(type.getHttpStatusCode())) + .body(new ExceptionResponse(type.name(), type.getMessage())); + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/global/exception/response/ExceptionResponse.java b/backend/pcloud-api/src/main/java/com/api/global/exception/response/ExceptionResponse.java new file mode 100644 index 00000000..202acdb3 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/global/exception/response/ExceptionResponse.java @@ -0,0 +1,7 @@ +package com.api.global.exception.response; + +public record ExceptionResponse( + String code, + String message +) { +} diff --git a/backend/pcloud-api/src/main/java/com/api/member/application/MemberService.java b/backend/pcloud-api/src/main/java/com/api/member/application/MemberService.java new file mode 100644 index 00000000..42a7d738 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/member/application/MemberService.java @@ -0,0 +1,53 @@ +package com.api.member.application; + +import com.api.member.application.request.LoginRequest; +import com.api.member.application.request.SignupRequest; +import com.common.auth.TokenProvider; +import com.domain.domains.member.domain.Member; +import com.domain.domains.member.domain.MemberRepository; +import com.domain.domains.member.exception.MemberException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.domain.domains.member.exception.MemberExceptionType.MEMBER_ALREADY_EXISTED_EXCEPTION; +import static com.domain.domains.member.exception.MemberExceptionType.MEMBER_NOT_FOUND_EXCEPTION; + +@RequiredArgsConstructor +@Service +public class MemberService { + + private final TokenProvider tokenProvider; + private final MemberRepository memberRepository; + + @Transactional + public String signup(final SignupRequest signupRequest) { + validateAlreadyExistedMember(signupRequest); + + Member member = Member.builder() + .email(signupRequest.email()) + .password(signupRequest.password()) + .build(); + + memberRepository.save(member); + return tokenProvider.create(member.getId()); + } + + private void validateAlreadyExistedMember(final SignupRequest signupRequest) { + if (memberRepository.existsByEmail(signupRequest.email())) { + throw new MemberException(MEMBER_ALREADY_EXISTED_EXCEPTION); + } + } + + @Transactional(readOnly = true) + public String login(final LoginRequest loginRequest) { + Member member = findMemberByEmail(loginRequest); + member.validatePassword(loginRequest.password()); + return tokenProvider.create(member.getId()); + } + + private Member findMemberByEmail(final LoginRequest loginRequest) { + return memberRepository.findByEmail(loginRequest.email()) + .orElseThrow(() -> new MemberException(MEMBER_NOT_FOUND_EXCEPTION)); + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/member/application/request/LoginRequest.java b/backend/pcloud-api/src/main/java/com/api/member/application/request/LoginRequest.java new file mode 100644 index 00000000..2d9b3708 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/member/application/request/LoginRequest.java @@ -0,0 +1,7 @@ +package com.api.member.application.request; + +public record LoginRequest( + String email, + String password +) { +} diff --git a/backend/pcloud-api/src/main/java/com/api/member/application/request/SignupRequest.java b/backend/pcloud-api/src/main/java/com/api/member/application/request/SignupRequest.java new file mode 100644 index 00000000..5a1f0d1c --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/member/application/request/SignupRequest.java @@ -0,0 +1,7 @@ +package com.api.member.application.request; + +public record SignupRequest( + String email, + String password +) { +} diff --git a/backend/pcloud-api/src/main/java/com/api/member/infrastructure/MemberRepositoryImpl.java b/backend/pcloud-api/src/main/java/com/api/member/infrastructure/MemberRepositoryImpl.java new file mode 100644 index 00000000..14ed02fd --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/member/infrastructure/MemberRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.api.member.infrastructure; + +import com.domain.domains.member.domain.Member; +import com.domain.domains.member.domain.MemberRepository; +import com.domain.domains.member.infrastructure.MemberJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(final Member member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findById(final Long id) { + return memberJpaRepository.findById(id); + } + + @Override + public Optional findByEmail(final String email) { + return memberJpaRepository.findByEmail(email); + } + + @Override + public boolean existsByEmail(final String email) { + return memberJpaRepository.existsByEmail(email); + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/member/presentation/MemberController.java b/backend/pcloud-api/src/main/java/com/api/member/presentation/MemberController.java new file mode 100644 index 00000000..8290c13e --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/member/presentation/MemberController.java @@ -0,0 +1,39 @@ +package com.api.member.presentation; + +import com.api.member.application.MemberService; +import com.api.member.application.request.LoginRequest; +import com.api.member.application.request.SignupRequest; +import com.api.member.presentation.response.TokenResponse; +import com.common.annotation.AuthMember; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/members") +@RestController +public class MemberController { + + private final MemberService memberService; + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody final SignupRequest signupRequest) { + String token = memberService.signup(signupRequest); + return ResponseEntity.ok(new TokenResponse(token)); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody final LoginRequest loginRequest) { + String token = memberService.login(loginRequest); + return ResponseEntity.ok(new TokenResponse(token)); + } + + @GetMapping("/test") + public ResponseEntity test(@AuthMember final Long memberId) { + return ResponseEntity.ok(memberId); + } +} diff --git a/backend/pcloud-api/src/main/java/com/api/member/presentation/response/TokenResponse.java b/backend/pcloud-api/src/main/java/com/api/member/presentation/response/TokenResponse.java new file mode 100644 index 00000000..4fcf57f7 --- /dev/null +++ b/backend/pcloud-api/src/main/java/com/api/member/presentation/response/TokenResponse.java @@ -0,0 +1,6 @@ +package com.api.member.presentation.response; + +public record TokenResponse( + String accessToken +) { +} diff --git a/backend/pcloud-api/src/main/resources/application.yml b/backend/pcloud-api/src/main/resources/application.yml index 9a790ee5..af3843a0 100644 --- a/backend/pcloud-api/src/main/resources/application.yml +++ b/backend/pcloud-api/src/main/resources/application.yml @@ -1,9 +1,10 @@ spring: profiles: - include: - - domain - - common - - infrastructure + active: local + +server: + servlet: + context-path: /api management: server: diff --git a/backend/pcloud-api/src/test/java/com/api/PCloudApiServerTest.java b/backend/pcloud-api/src/test/java/com/api/PCloudApiServerTest.java index 9dbd31e5..a858a04b 100644 --- a/backend/pcloud-api/src/test/java/com/api/PCloudApiServerTest.java +++ b/backend/pcloud-api/src/test/java/com/api/PCloudApiServerTest.java @@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -//@SpringBootTest +@SpringBootTest class PCloudApiServerTest { @Test diff --git a/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/AuthenticationContextTest.java b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/AuthenticationContextTest.java new file mode 100644 index 00000000..d83bc22b --- /dev/null +++ b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/AuthenticationContextTest.java @@ -0,0 +1,59 @@ +package com.api.config.interceptor.auth.support; + +import com.api.global.config.interceptor.auth.support.AuthenticationContext; +import com.common.exception.AuthException; +import com.common.exception.AuthExceptionType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AuthenticationContextTest { + + private AuthenticationContext authenticationContext; + + @BeforeEach + void setup() { + authenticationContext = new AuthenticationContext(); + } + + @Test + void member_id를_반환한다() { + // given + authenticationContext.setAuthentication(1L); + + // when + Long result = authenticationContext.getPrincipal(); + + // then + assertThat(result).isEqualTo(1L); + } + + @Test + void member_id가_없다면_예외를_발생한다() { + // given + authenticationContext.setAuthentication(null); + + // when & then + assertThatThrownBy(() -> authenticationContext.getPrincipal()) + .isInstanceOf(AuthException.class) + .hasMessageContaining(AuthExceptionType.LOGIN_INVALID_EXCEPTION.getMessage()); + } + + @Test + void 미확인_유저로_바꾼다() { + // given + authenticationContext.setAnonymous(); + + // when + Long result = authenticationContext.getPrincipal(); + + // then + assertThat(result).isEqualTo(-1L); + } +} diff --git a/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/AuthenticationExtractorTest.java b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/AuthenticationExtractorTest.java new file mode 100644 index 00000000..dd728707 --- /dev/null +++ b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/AuthenticationExtractorTest.java @@ -0,0 +1,51 @@ +package com.api.config.interceptor.auth.support; + +import com.api.global.config.interceptor.auth.support.AuthenticationExtractor; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.Mockito.when; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AuthenticationExtractorTest { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + + private final HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + + @Test + void 토큰이_정상적으로_조회된다() { + // given + String expectedResponseToken = "Bearer tokenSignature"; + when(request.getHeader(AUTHORIZATION_HEADER)).thenReturn(expectedResponseToken); + + // when + Optional result = AuthenticationExtractor.extract(request); + + // then + assertSoftly(softly -> { + softly.assertThat(result).isPresent(); + softly.assertThat(result).isEqualTo(Optional.of("tokenSignature")); + }); + } + + @Test + void 토큰_헤더가_없다면_빈_값이_반환된다() { + // given + when(request.getHeader(AUTHORIZATION_HEADER)).thenReturn("InvalidType token"); + + // when + Optional result = AuthenticationExtractor.extract(request); + + // then + assertThat(result).isEmpty(); + } +} diff --git a/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/HttpMethodTest.java b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/HttpMethodTest.java new file mode 100644 index 00000000..40d422e2 --- /dev/null +++ b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/HttpMethodTest.java @@ -0,0 +1,25 @@ +package com.api.config.interceptor.auth.support; + +import com.api.global.config.interceptor.auth.support.HttpMethod; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class HttpMethodTest { + + @Test + void http_메서드가_같은지_확인한다() { + // given + HttpMethod httpMethod = HttpMethod.GET; + + // when + boolean result = httpMethod.matches(HttpMethod.GET.name()); + + // then + assertThat(result).isTrue(); + } +} diff --git a/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/LoginValidCheckerInterceptorTest.java b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/LoginValidCheckerInterceptorTest.java new file mode 100644 index 00000000..acd9c003 --- /dev/null +++ b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/LoginValidCheckerInterceptorTest.java @@ -0,0 +1,40 @@ +package com.api.config.interceptor.auth.support; + +import com.api.global.config.interceptor.auth.LoginValidCheckerInterceptor; +import com.api.global.config.interceptor.auth.support.AuthenticationContext; +import com.common.auth.JwtTokenProvider; +import com.common.exception.AuthException; +import com.common.exception.AuthExceptionType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class LoginValidCheckerInterceptorTest { + + private final HttpServletRequest req = mock(HttpServletRequest.class); + private final HttpServletResponse res = mock(HttpServletResponse.class); + + @Test + void token이_없다면_예외를_발생한다() { + // given + LoginValidCheckerInterceptor loginValidCheckerInterceptor = new LoginValidCheckerInterceptor( + new JwtTokenProvider(), + new AuthenticationContext() + ); + + when(req.getHeader("any")).thenReturn(null); + + // when + assertThatThrownBy(() -> loginValidCheckerInterceptor.preHandle(req, res, new Object())) + .isInstanceOf(AuthException.class) + .hasMessageContaining(AuthExceptionType.SIGNATURE_INVALID_EXCEPTION.getMessage()); + } +} diff --git a/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/PathContainerTest.java b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/PathContainerTest.java new file mode 100644 index 00000000..3f50c3b2 --- /dev/null +++ b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/PathContainerTest.java @@ -0,0 +1,67 @@ +package com.api.config.interceptor.auth.support; + +import com.api.global.config.interceptor.auth.support.HttpMethod; +import com.api.global.config.interceptor.auth.support.PathContainer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class PathContainerTest { + + private PathContainer container; + + @BeforeEach + void setup() { + container = new PathContainer(); + } + + @Test + void include로_등록한_메서드와_uri가_같으면_false를_반환한다() { + // given + String uri = "/url/test"; + HttpMethod method = HttpMethod.GET; + + container.addIncludePatterns(uri, method); + + // when + boolean result = container.isNotIncludedPath(uri, method.name()); + + // then + assertThat(result).isFalse(); + } + + @Test + void include로_등록한_메서드와_uri가_다르면_true를_반환한다() { + // given + String uri = "/url/test"; + HttpMethod method = HttpMethod.GET; + + container.addIncludePatterns(uri, method); + + // when + boolean result = container.isNotIncludedPath(uri + "wrong", HttpMethod.GET.name()); + + // then + assertThat(result).isTrue(); + } + + @Test + void exclude로_등록한_메서드와_uri가_같으면_true를_반환한다() { + // given + String uri = "/url/test"; + HttpMethod method = HttpMethod.GET; + + container.addExcludePatterns(uri, method); + + // when + boolean result = container.isNotIncludedPath(uri, method.name()); + + // then + assertThat(result).isTrue(); + } +} diff --git a/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/PathRequestTest.java b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/PathRequestTest.java new file mode 100644 index 00000000..434f8c97 --- /dev/null +++ b/backend/pcloud-api/src/test/java/com/api/config/interceptor/auth/support/PathRequestTest.java @@ -0,0 +1,30 @@ +package com.api.config.interceptor.auth.support; + +import com.api.global.config.interceptor.auth.support.HttpMethod; +import com.api.global.config.interceptor.auth.support.PathRequest; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.util.AntPathMatcher; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class PathRequestTest { + + @Test + void uri와_method가_같은지_확인한다() { + // given + String path = "/path"; + HttpMethod method = HttpMethod.GET; + + PathRequest pathRequest = new PathRequest(path, method); + + // when + boolean result = pathRequest.matches(new AntPathMatcher(), path, method.name()); + + // then + assertThat(result).isTrue(); + } +} diff --git a/backend/pcloud-api/src/test/java/com/api/helper/IntegrationHelper.java b/backend/pcloud-api/src/test/java/com/api/helper/IntegrationHelper.java index 6f3f152c..c97fe18f 100644 --- a/backend/pcloud-api/src/test/java/com/api/helper/IntegrationHelper.java +++ b/backend/pcloud-api/src/test/java/com/api/helper/IntegrationHelper.java @@ -6,12 +6,10 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.support.AbstractTestExecutionListener; import java.util.List; -@ActiveProfiles("local") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class IntegrationHelper extends AbstractTestExecutionListener { diff --git a/backend/pcloud-api/src/test/java/com/api/helper/MockBeanInjection.java b/backend/pcloud-api/src/test/java/com/api/helper/MockBeanInjection.java index 37ab3671..e8268edb 100644 --- a/backend/pcloud-api/src/test/java/com/api/helper/MockBeanInjection.java +++ b/backend/pcloud-api/src/test/java/com/api/helper/MockBeanInjection.java @@ -1,9 +1,20 @@ package com.api.helper; +import com.api.global.config.interceptor.auth.support.AuthenticationContext; +import com.api.member.application.MemberService; +import com.common.auth.TokenProvider; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +@MockBean(JpaMetamodelMappingContext.class) public class MockBeanInjection { - /*@MockBean - protected Service service;*/ + @MockBean + protected TokenProvider tokenProvider; + + @MockBean + protected AuthenticationContext authenticationContext; + + @MockBean + protected MemberService memberService; } diff --git a/backend/pcloud-api/src/test/java/com/api/member/application/MemberServiceTest.java b/backend/pcloud-api/src/test/java/com/api/member/application/MemberServiceTest.java new file mode 100644 index 00000000..249a8497 --- /dev/null +++ b/backend/pcloud-api/src/test/java/com/api/member/application/MemberServiceTest.java @@ -0,0 +1,113 @@ +package com.api.member.application; + +import auth.FakeTokenProvider; +import com.api.member.application.request.LoginRequest; +import com.api.member.application.request.SignupRequest; +import com.common.auth.TokenProvider; +import com.domain.domains.member.domain.Member; +import com.domain.domains.member.domain.MemberRepository; +import com.domain.domains.member.exception.MemberException; +import com.domain.domains.member.exception.MemberExceptionType; +import member.fixture.FakeMemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static member.fixture.MemberFixture.일반_멤버_생성_id_없음; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AuthServiceTest { + + private TokenProvider tokenProvider; + private MemberService memberService; + private MemberRepository memberRepository; + + @BeforeEach + void setup() { + memberRepository = new FakeMemberRepository(); + tokenProvider = new FakeTokenProvider(); + memberService = new MemberService(tokenProvider, memberRepository); + } + + @DisplayName("회원가입을 진행한다") + @Nested + class Signup { + + @Test + void 회원가입을_성공한다() { + // given + SignupRequest req = new SignupRequest("email", "password"); + + // when + String result = memberService.signup(req); + + // then + assertThat(result).isEqualTo("token"); + } + + @Test + void 이미_존재하는_이메일이라면_예외를_발생한다() { + // given + Member existedMember = 일반_멤버_생성_id_없음(); + memberRepository.save(existedMember); + + SignupRequest req = new SignupRequest(existedMember.getEmail(), "password"); + + // when & then + assertThatThrownBy(() -> memberService.signup(req)) + .isInstanceOf(MemberException.class) + .hasMessageContaining(MemberExceptionType.MEMBER_ALREADY_EXISTED_EXCEPTION.getMessage()); + } + } + + @DisplayName("로그인을 진행한다") + @Nested + class Login { + + @Test + void 로그인을_성공적으로_진행한다() { + // given + Member member = memberRepository.save(일반_멤버_생성_id_없음()); + LoginRequest request = new LoginRequest(member.getEmail(), member.getPassword()); + String expectedToken = "token"; + + // when + String result = memberService.login(request); + + // then + assertThat(result).isEqualTo(expectedToken); + } + + @Test + void 존재하지_않는_이메일로_로그인시_예외를_발생한다() { + // given + Member member = memberRepository.save(일반_멤버_생성_id_없음()); + String wrongEmail = "wrong"; + LoginRequest request = new LoginRequest(wrongEmail, member.getPassword()); + + // when & then + assertThatThrownBy(() -> memberService.login(request)) + .isInstanceOf(MemberException.class) + .hasMessageContaining(MemberExceptionType.MEMBER_NOT_FOUND_EXCEPTION.getMessage()); + } + + @Test + void 패스워드가_틀리면_예외를_발생한다() { + // given + Member member = memberRepository.save(일반_멤버_생성_id_없음()); + String wrongPassword = "wrong"; + LoginRequest request = new LoginRequest(member.getEmail(), wrongPassword); + + // when & then + assertThatThrownBy(() -> memberService.login(request)) + .isInstanceOf(MemberException.class) + .hasMessageContaining(MemberExceptionType.PASSWORD_INVALID_EXCEPTION.getMessage()); + } + } +} diff --git a/backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerAcceptanceFixture.java b/backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerAcceptanceFixture.java new file mode 100644 index 00000000..a7ace3ab --- /dev/null +++ b/backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerAcceptanceFixture.java @@ -0,0 +1,51 @@ +package com.api.member.presentation; + +import com.api.helper.IntegrationHelper; +import com.api.member.application.request.LoginRequest; +import com.api.member.application.request.SignupRequest; +import com.api.member.presentation.response.TokenResponse; +import com.domain.domains.member.domain.Member; +import com.domain.domains.member.domain.MemberRepository; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.ExtractableResponse; +import org.springframework.beans.factory.annotation.Autowired; + +import static member.fixture.MemberFixture.일반_멤버_생성_id_없음; +import static org.assertj.core.api.Assertions.assertThat; + +class MemberControllerAcceptanceFixture extends IntegrationHelper { + + protected static final String 회원가입_url = "/members/signup"; + protected static final String 로그인_url = "/members/login"; + + @Autowired + private MemberRepository memberRepository; + + protected SignupRequest 회원_가입_데이터를_요청한다() { + return new SignupRequest("email", "password"); + } + + protected Member 회원_생성() { + return memberRepository.save(일반_멤버_생성_id_없음()); + } + + protected ExtractableResponse 요청한다(final T request, final String url) { + return RestAssured.given().log().all() + .body(request) + .contentType(ContentType.JSON) + .when() + .post(url) + .then().log().all() + .extract(); + } + + protected void 토큰_생성_검증(final ExtractableResponse actual) { + var result = actual.as(TokenResponse.class); + assertThat(result.accessToken()).isNotBlank(); + } + + protected LoginRequest 로그인_데이터를_요청한다(final Member member) { + return new LoginRequest(member.getEmail(), member.getPassword()); + } +} diff --git a/backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerAcceptanceTest.java b/backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerAcceptanceTest.java new file mode 100644 index 00000000..8671559d --- /dev/null +++ b/backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerAcceptanceTest.java @@ -0,0 +1,35 @@ +package com.api.member.presentation; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberControllerAcceptanceTest extends MemberControllerAcceptanceFixture { + + @Test + void 회원가입을_진행한다() { + // given + var 회원가입_요청_데이터 = 회원_가입_데이터를_요청한다(); + + // when + var 회원가입_결과 = 요청한다(회원가입_요청_데이터, 회원가입_url); + + // then + 토큰_생성_검증(회원가입_결과); + } + + @Test + void 로그인을_진행한다() { + // given + var 회원 = 회원_생성(); + var 로그인_요청_데이터 = 로그인_데이터를_요청한다(회원); + + // when + var 로그인_결과 = 요청한다(로그인_요청_데이터, 로그인_url); + + // then + 토큰_생성_검증(로그인_결과); + } +} diff --git a/backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerWebMvcTest.java b/backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerWebMvcTest.java new file mode 100644 index 00000000..a6b5b4f2 --- /dev/null +++ b/backend/pcloud-api/src/test/java/com/api/member/presentation/MemberControllerWebMvcTest.java @@ -0,0 +1,80 @@ +package com.api.member.presentation; + +import com.api.helper.MockBeanInjection; +import com.api.member.application.request.LoginRequest; +import com.api.member.application.request.SignupRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.test.web.servlet.MockMvc; + +import static com.api.helper.RestDocsHelper.customDocument; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@AutoConfigureRestDocs +@WebMvcTest(MemberController.class) +class MemberControllerTest extends MockBeanInjection { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void 회원가입을_진행한다() throws Exception { + // given + SignupRequest req = new SignupRequest("email@email.com", "passsword"); + when(memberService.signup(req)).thenReturn("response_token_info"); + + // when & then + mockMvc.perform(post("/members/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + ).andExpect(status().isOk()) + .andDo(customDocument("do_signup", + requestFields( + fieldWithPath("email").description("이메일"), + fieldWithPath("password").description("패스워드") + ), + responseFields( + fieldWithPath("accessToken").description("발급되는 토큰") + ) + )); + } + + @Test + void 로그인을_진행한다() throws Exception { + // given + LoginRequest req = new LoginRequest("email@email.com", "password"); + when(memberService.login(req)).thenReturn("response_token_info"); + + // when & then + mockMvc.perform(post("/members/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + ).andExpect(status().isOk()) + .andDo(customDocument("do_login", + requestFields( + fieldWithPath("email").description("이메일"), + fieldWithPath("password").description("패스워드") + ), + responseFields( + fieldWithPath("accessToken").description("발급되는 토큰") + ) + )); + } + +} diff --git a/backend/pcloud-api/src/test/resources/application.yml b/backend/pcloud-api/src/test/resources/application.yml index d0c53228..6b5f8dd9 100644 --- a/backend/pcloud-api/src/test/resources/application.yml +++ b/backend/pcloud-api/src/test/resources/application.yml @@ -4,7 +4,4 @@ spring: - domain - common - infrastructure - -jasypt: - encryptor: - password: password + active: test diff --git a/backend/pcloud-common/build.gradle b/backend/pcloud-common/build.gradle index 946b2a28..74591fe7 100644 --- a/backend/pcloud-common/build.gradle +++ b/backend/pcloud-common/build.gradle @@ -2,14 +2,11 @@ bootJar.enabled = false jar.enabled = true dependencies { - // *** 의존 범위까지 적용되는 라이브러리 api *** - api 'org.springframework.boot:spring-boot-starter-validation' - // jasypt api 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5' + api 'org.springframework.boot:spring-boot-starter-validation' - // *** common 모듈 내부 사용 라이브러리 *** implementation 'org.springframework.boot:spring-boot-starter-aop' // jwt diff --git a/backend/pcloud-common/src/main/java/com/common/annotation/AuthMember.java b/backend/pcloud-common/src/main/java/com/common/annotation/AuthMember.java new file mode 100644 index 00000000..e6fa6bf6 --- /dev/null +++ b/backend/pcloud-common/src/main/java/com/common/annotation/AuthMember.java @@ -0,0 +1,11 @@ +package com.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMember { +} diff --git a/backend/pcloud-common/src/main/java/com/common/auth/JwtTokenProvider.java b/backend/pcloud-common/src/main/java/com/common/auth/JwtTokenProvider.java new file mode 100644 index 00000000..5d6a0aca --- /dev/null +++ b/backend/pcloud-common/src/main/java/com/common/auth/JwtTokenProvider.java @@ -0,0 +1,98 @@ +package com.common.auth; + +import com.common.exception.AuthException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +import static com.common.exception.AuthExceptionType.EXPIRED_TOKEN_EXCEPTION; +import static com.common.exception.AuthExceptionType.SIGNATURE_INVALID_EXCEPTION; +import static com.common.exception.AuthExceptionType.TOKEN_FORM_INVALID_EXCEPTION; +import static com.common.exception.AuthExceptionType.TOKEN_INVALID_EXCEPTION; +import static com.common.exception.AuthExceptionType.UNSUPPORTED_TOKEN_EXCEPTION; + +@Getter +@NoArgsConstructor +@Component +public class JwtTokenProvider implements TokenProvider { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration-period}") + private int expirationPeriod; + + private Key key; + + @PostConstruct + private void init() { + key = Keys.hmacShaKeyFor(secret.getBytes()); + } + + @Override + public String create(final Long id) { + Claims claims = Jwts.claims(); + claims.put("id", id); + return createToken(claims); + } + + private String createToken(final Claims claims) { + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(issuedAt()) + .setExpiration(expiredAt()) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + private Date issuedAt() { + LocalDateTime now = LocalDateTime.now(); + + return Date.from(now.atZone(ZoneId.systemDefault()) + .toInstant()); + } + + private Date expiredAt() { + LocalDateTime now = LocalDateTime.now(); + + return Date.from(now.plusHours(expirationPeriod) + .atZone(ZoneId.systemDefault()) + .toInstant()); + } + + @Override + public Long extract(final String token) { + try { + Long id = Jwts.parser() + .setSigningKey(secret.getBytes()) + .parseClaimsJws(token) + .getBody() + .get("id", Long.class); + return id; + } catch (final SecurityException e) { + throw new AuthException(SIGNATURE_INVALID_EXCEPTION); + } catch (final MalformedJwtException e) { + throw new AuthException(TOKEN_FORM_INVALID_EXCEPTION); + } catch (final ExpiredJwtException e) { + throw new AuthException(EXPIRED_TOKEN_EXCEPTION); + } catch (final UnsupportedJwtException e) { + throw new AuthException(UNSUPPORTED_TOKEN_EXCEPTION); + } catch (final IllegalArgumentException e) { + throw new AuthException(TOKEN_INVALID_EXCEPTION); + } + } +} diff --git a/backend/pcloud-common/src/main/java/com/common/auth/TokenProvider.java b/backend/pcloud-common/src/main/java/com/common/auth/TokenProvider.java new file mode 100644 index 00000000..501e0a31 --- /dev/null +++ b/backend/pcloud-common/src/main/java/com/common/auth/TokenProvider.java @@ -0,0 +1,8 @@ +package com.common.auth; + +public interface TokenProvider { + + String create(Long id); + + Long extract(String token); +} diff --git a/backend/pcloud-common/src/main/java/com/common/exception/AuthException.java b/backend/pcloud-common/src/main/java/com/common/exception/AuthException.java new file mode 100644 index 00000000..56b2c15e --- /dev/null +++ b/backend/pcloud-common/src/main/java/com/common/exception/AuthException.java @@ -0,0 +1,16 @@ +package com.common.exception; + +public class AuthException extends CustomException { + + private final AuthExceptionType authException; + + public AuthException(final AuthExceptionType authException) { + super(authException); + this.authException = authException; + } + + @Override + public AuthExceptionType getExceptionType() { + return authException; + } +} diff --git a/backend/pcloud-common/src/main/java/com/common/exception/AuthExceptionType.java b/backend/pcloud-common/src/main/java/com/common/exception/AuthExceptionType.java new file mode 100644 index 00000000..2bc7a790 --- /dev/null +++ b/backend/pcloud-common/src/main/java/com/common/exception/AuthExceptionType.java @@ -0,0 +1,29 @@ +package com.common.exception; + +public enum AuthExceptionType implements CustomExceptionType { + + EXPIRED_TOKEN_EXCEPTION(401, "토큰이 만료되었습니다."), + LOGIN_INVALID_EXCEPTION(401, "로그인 정보를 찾을 수 없습니다."), + SIGNATURE_INVALID_EXCEPTION(401, "토큰의 서명이 잘못 되었습니다."), + TOKEN_FORM_INVALID_EXCEPTION(400, "토큰의 형식이 올바르지 않습니다."), + TOKEN_INVALID_EXCEPTION(400, "토큰의 값이 유효하지 않습니다."), + UNSUPPORTED_TOKEN_EXCEPTION(400, "지원하지 않는 토큰 형식입니다."); + + private final int httpStatusCode; + private final String message; + + AuthExceptionType(final int httpStatusCode, final String message) { + this.httpStatusCode = httpStatusCode; + this.message = message; + } + + @Override + public String getMessage() { + return this.message; + } + + @Override + public int getHttpStatusCode() { + return this.httpStatusCode; + } +} diff --git a/backend/pcloud-common/src/main/java/com/common/exception/CustomException.java b/backend/pcloud-common/src/main/java/com/common/exception/CustomException.java new file mode 100644 index 00000000..ba77722e --- /dev/null +++ b/backend/pcloud-common/src/main/java/com/common/exception/CustomException.java @@ -0,0 +1,10 @@ +package com.common.exception; + +public abstract class CustomException extends RuntimeException { + + protected CustomException(final CustomExceptionType customExceptionType) { + super("[%s]: %s".formatted(customExceptionType.name(), customExceptionType.getMessage())); + } + + public abstract CustomExceptionType getExceptionType(); +} diff --git a/backend/pcloud-common/src/main/java/com/common/exception/CustomExceptionType.java b/backend/pcloud-common/src/main/java/com/common/exception/CustomExceptionType.java new file mode 100644 index 00000000..aaf2326a --- /dev/null +++ b/backend/pcloud-common/src/main/java/com/common/exception/CustomExceptionType.java @@ -0,0 +1,10 @@ +package com.common.exception; + +public interface CustomExceptionType { + + String getMessage(); + + String name(); + + int getHttpStatusCode(); +} diff --git a/backend/pcloud-common/src/main/resources/application-common.yml b/backend/pcloud-common/src/main/resources/application-common-local.yml similarity index 100% rename from backend/pcloud-common/src/main/resources/application-common.yml rename to backend/pcloud-common/src/main/resources/application-common-local.yml diff --git a/backend/pcloud-common/src/main/resources/application-common-prod.yml b/backend/pcloud-common/src/main/resources/application-common-prod.yml new file mode 100644 index 00000000..a3c6fc4a --- /dev/null +++ b/backend/pcloud-common/src/main/resources/application-common-prod.yml @@ -0,0 +1,8 @@ +jasypt: + encryptor: + bean: jasyptEncryptor + password: ${ENCRYPT_KEY} + +jwt: + secret: ENC(hQLPUuufo6+rX3SKuCA1WOTUqjyLREZsBF1q4dFrB2+WLMQNVyGhB4d2zskhI6sdCWgDaPBDABZFIg6nk3eLtZErUieH1I33) + expiration-period: ENC(akNgWoo0R4MHHtv0+2iBQw==) diff --git a/backend/pcloud-common/src/main/resources/application-common-test.yml b/backend/pcloud-common/src/main/resources/application-common-test.yml new file mode 100644 index 00000000..489f805c --- /dev/null +++ b/backend/pcloud-common/src/main/resources/application-common-test.yml @@ -0,0 +1,7 @@ +jasypt: + encryptor: + password: password + +jwt: + secret: testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest + expiration-period: 10000 diff --git a/backend/pcloud-common/src/testFixtures/java/auth/FakeTokenProvider.java b/backend/pcloud-common/src/testFixtures/java/auth/FakeTokenProvider.java new file mode 100644 index 00000000..bc151a7c --- /dev/null +++ b/backend/pcloud-common/src/testFixtures/java/auth/FakeTokenProvider.java @@ -0,0 +1,16 @@ +package auth; + +import com.common.auth.TokenProvider; + +public class FakeTokenProvider implements TokenProvider { + + @Override + public String create(final Long id) { + return "token"; + } + + @Override + public Long extract(final String token) { + return 1L; + } +} diff --git a/backend/pcloud-domain/src/main/java/com/domain/domains/member/domain/Member.java b/backend/pcloud-domain/src/main/java/com/domain/domains/member/domain/Member.java new file mode 100644 index 00000000..db846e72 --- /dev/null +++ b/backend/pcloud-domain/src/main/java/com/domain/domains/member/domain/Member.java @@ -0,0 +1,43 @@ +package com.domain.domains.member.domain; + +import com.domain.domains.base.BaseEntity; +import com.domain.domains.member.exception.MemberException; +import com.domain.domains.member.exception.MemberExceptionType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@Builder +@EqualsAndHashCode(of = "id", callSuper = false) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@ToString +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private String email; + + @Column + private String password; + + public void validatePassword(final String password) { + if (!this.password.equals(password)) { + throw new MemberException(MemberExceptionType.PASSWORD_INVALID_EXCEPTION); + } + } +} diff --git a/backend/pcloud-domain/src/main/java/com/domain/domains/member/domain/MemberRepository.java b/backend/pcloud-domain/src/main/java/com/domain/domains/member/domain/MemberRepository.java new file mode 100644 index 00000000..38244fce --- /dev/null +++ b/backend/pcloud-domain/src/main/java/com/domain/domains/member/domain/MemberRepository.java @@ -0,0 +1,14 @@ +package com.domain.domains.member.domain; + +import java.util.Optional; + +public interface MemberRepository { + + Member save(Member member); + + Optional findById(Long id); + + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/backend/pcloud-domain/src/main/java/com/domain/domains/member/exception/MemberException.java b/backend/pcloud-domain/src/main/java/com/domain/domains/member/exception/MemberException.java new file mode 100644 index 00000000..7467e0c7 --- /dev/null +++ b/backend/pcloud-domain/src/main/java/com/domain/domains/member/exception/MemberException.java @@ -0,0 +1,19 @@ +package com.domain.domains.member.exception; + +import com.common.exception.CustomException; +import com.common.exception.CustomExceptionType; + +public class MemberException extends CustomException { + + private final MemberExceptionType memberExceptionType; + + public MemberException(final MemberExceptionType memberExceptionType) { + super(memberExceptionType); + this.memberExceptionType = memberExceptionType; + } + + @Override + public CustomExceptionType getExceptionType() { + return memberExceptionType; + } +} diff --git a/backend/pcloud-domain/src/main/java/com/domain/domains/member/exception/MemberExceptionType.java b/backend/pcloud-domain/src/main/java/com/domain/domains/member/exception/MemberExceptionType.java new file mode 100644 index 00000000..2ad4c422 --- /dev/null +++ b/backend/pcloud-domain/src/main/java/com/domain/domains/member/exception/MemberExceptionType.java @@ -0,0 +1,30 @@ +package com.domain.domains.member.exception; + +import com.common.exception.CustomExceptionType; + +public enum MemberExceptionType implements CustomExceptionType { + + MEMBER_ALREADY_EXISTED_EXCEPTION(400, "Member가 이미 존재합니다."), + MEMBER_NOT_FOUND_EXCEPTION(404, "Member가 존재하지 않습니다."), + PASSWORD_INVALID_EXCEPTION(401, "패스워드가 일치하지 않습니다."), + ; + + private final int httpStatusCode; + private final String message; + + MemberExceptionType(final int httpStatusCode, final String message) { + this.httpStatusCode = httpStatusCode; + this.message = message; + } + + + @Override + public String getMessage() { + return this.message; + } + + @Override + public int getHttpStatusCode() { + return this.httpStatusCode; + } +} diff --git a/backend/pcloud-domain/src/main/java/com/domain/domains/member/infrastructure/MemberJpaRepository.java b/backend/pcloud-domain/src/main/java/com/domain/domains/member/infrastructure/MemberJpaRepository.java new file mode 100644 index 00000000..9d04713e --- /dev/null +++ b/backend/pcloud-domain/src/main/java/com/domain/domains/member/infrastructure/MemberJpaRepository.java @@ -0,0 +1,17 @@ +package com.domain.domains.member.infrastructure; + +import com.domain.domains.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + Member save(Member member); + + Optional findById(Long id); + + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/backend/pcloud-domain/src/main/resources/application-domain-local.yml b/backend/pcloud-domain/src/main/resources/application-domain-local.yml new file mode 100644 index 00000000..108f9167 --- /dev/null +++ b/backend/pcloud-domain/src/main/resources/application-domain-local.yml @@ -0,0 +1,19 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/pcloud?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true + username: root + password: root + driver-class-name: com.mysql.cj.jdbc.Driver + + flyway: + enabled: false + baseline-on-migrate: false + + jpa: + hibernate: + ddl-auto: create + + properties: + hibernate: + format_sql: true + show_sql: true diff --git a/backend/pcloud-domain/src/main/resources/application-domain-prod.yml b/backend/pcloud-domain/src/main/resources/application-domain-prod.yml new file mode 100644 index 00000000..6c4d8307 --- /dev/null +++ b/backend/pcloud-domain/src/main/resources/application-domain-prod.yml @@ -0,0 +1,14 @@ +spring: + datasource: + url: jdbc:mysql://prod:3306/pcloud?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true + username: prod + password: prod + driver-class-name: com.mysql.cj.jdbc.Driver + + flyway: + enabled: true + baseline-on-migrate: true + + jpa: + hibernate: + ddl-auto: validate diff --git a/backend/pcloud-domain/src/main/resources/application-domain-test.yml b/backend/pcloud-domain/src/main/resources/application-domain-test.yml new file mode 100644 index 00000000..37d866da --- /dev/null +++ b/backend/pcloud-domain/src/main/resources/application-domain-test.yml @@ -0,0 +1,17 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_LOWER=TRUE + username: sa + + flyway: + enabled: false + baseline-on-migrate: false + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true diff --git a/backend/pcloud-domain/src/main/resources/application-domain.yml b/backend/pcloud-domain/src/main/resources/application-domain.yml deleted file mode 100644 index 76fa0233..00000000 --- a/backend/pcloud-domain/src/main/resources/application-domain.yml +++ /dev/null @@ -1,42 +0,0 @@ ---- -spring: - config: - activate: - on-profile: local - datasource: - url: jdbc:mysql://localhost:3306/pcloud?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true - username: root - password: root - driver-class-name: com.mysql.cj.jdbc.Driver - - flyway: - enabled: false - baseline-on-migrate: false - - jpa: - hibernate: - ddl-auto: create - - properties: - hibernate: - format_sql: true - show_sql: true ---- -spring: - config: - activate: - on-profile: prod - datasource: - url: jdbc:mysql://prod:3306/pcloud?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true - username: prod - password: prod - driver-class-name: com.mysql.cj.jdbc.Driver - - flyway: - enabled: true - baseline-on-migrate: true - - jpa: - hibernate: - ddl-auto: validate ---- diff --git a/backend/pcloud-domain/src/test/java/com/domain/domains/member/domain/MemberTest.java b/backend/pcloud-domain/src/test/java/com/domain/domains/member/domain/MemberTest.java new file mode 100644 index 00000000..650cfc92 --- /dev/null +++ b/backend/pcloud-domain/src/test/java/com/domain/domains/member/domain/MemberTest.java @@ -0,0 +1,26 @@ +package com.domain.domains.member.domain; + +import com.domain.domains.member.exception.MemberException; +import com.domain.domains.member.exception.MemberExceptionType; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +import static member.fixture.MemberFixture.일반_멤버_생성_id_없음; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberTest { + + @Test + void 패스워드가_일치하지_않으면_예외를_발생시킨다() { + // given + Member member = 일반_멤버_생성_id_없음(); + + // when & then + assertThatThrownBy(() -> member.validatePassword("wrong_password")) + .isInstanceOf(MemberException.class) + .hasMessageContaining(MemberExceptionType.PASSWORD_INVALID_EXCEPTION.getMessage()); + } +} diff --git a/backend/pcloud-domain/src/testFixtures/java/member/fixture/FakeMemberRepository.java b/backend/pcloud-domain/src/testFixtures/java/member/fixture/FakeMemberRepository.java new file mode 100644 index 00000000..2021be8f --- /dev/null +++ b/backend/pcloud-domain/src/testFixtures/java/member/fixture/FakeMemberRepository.java @@ -0,0 +1,46 @@ +package member.fixture; + +import com.domain.domains.member.domain.Member; +import com.domain.domains.member.domain.MemberRepository; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class FakeMemberRepository implements MemberRepository { + + private final Map map = new HashMap<>(); + private Long id = 0L; + + @Override + public Member save(final Member member) { + Member savedMember = Member.builder() + .id(id) + .email(member.getEmail()) + .password(member.getPassword()) + .build(); + + map.put(id, savedMember); + id++; + + return savedMember; + } + + @Override + public Optional findById(final Long id) { + return Optional.ofNullable(map.get(id)); + } + + @Override + public Optional findByEmail(final String email) { + return map.values().stream() + .filter(member -> member.getEmail().equals(email)) + .findAny(); + } + + @Override + public boolean existsByEmail(final String email) { + return map.values().stream() + .anyMatch(member -> member.getEmail().equals(email)); + } +} diff --git a/backend/pcloud-domain/src/testFixtures/java/member/fixture/MemberFixture.java b/backend/pcloud-domain/src/testFixtures/java/member/fixture/MemberFixture.java new file mode 100644 index 00000000..355ee7d0 --- /dev/null +++ b/backend/pcloud-domain/src/testFixtures/java/member/fixture/MemberFixture.java @@ -0,0 +1,13 @@ +package member.fixture; + +import com.domain.domains.member.domain.Member; + +public class MemberFixture { + + public static Member 일반_멤버_생성_id_없음() { + return Member.builder() + .email("email@email.com") + .password("1234") + .build(); + } +} diff --git a/backend/pcloud-infrastructure/build.gradle b/backend/pcloud-infrastructure/build.gradle index f0d6b510..ecc46c2c 100644 --- a/backend/pcloud-infrastructure/build.gradle +++ b/backend/pcloud-infrastructure/build.gradle @@ -3,4 +3,6 @@ jar { enabled = true } dependencies { implementation project(':pcloud-common') + testImplementation(testFixtures(project(":pcloud-domain"))) + testImplementation(testFixtures(project(":pcloud-common"))) } diff --git a/backend/pcloud-infrastructure/src/main/resources/application-infrastructure.yml b/backend/pcloud-infrastructure/src/main/resources/application-infrastructure-local.yml similarity index 100% rename from backend/pcloud-infrastructure/src/main/resources/application-infrastructure.yml rename to backend/pcloud-infrastructure/src/main/resources/application-infrastructure-local.yml diff --git a/backend/pcloud-infrastructure/src/main/resources/application-infrastructure-prod.yml b/backend/pcloud-infrastructure/src/main/resources/application-infrastructure-prod.yml new file mode 100644 index 00000000..e69de29b diff --git a/backend/pcloud-infrastructure/src/main/resources/application-infrastructure-test.yml b/backend/pcloud-infrastructure/src/main/resources/application-infrastructure-test.yml new file mode 100644 index 00000000..e69de29b