diff --git a/.gitignore b/.gitignore index c1809cd4..833a07c8 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ src/backend/**/src/main/resources/bootstrap.yml # Ignore all generated directories under backend src/backend/**/generated/ + +# http env +src/backend/**/http/http-client.private.env.json \ No newline at end of file diff --git a/config/user-server.yml b/config/user-server.yml index ef32bcea..23ce8572 100644 --- a/config/user-server.yml +++ b/config/user-server.yml @@ -4,6 +4,8 @@ eureka: defaultZone: '{cipher}f0d4232ceb8d3d476ebc0895b8ef4530a1a8992acaa373a904e559c3ac3f7b6054801cb979d0b481124de1cee9dd6633' jwt: secret-key: '{cipher}e3ee7c81dedbd05d33e9e934a192a67cca17890d39faebc6645fe3eeb6c7b16034312dad499dd72f869cb03a6f2d6b7498b5204a523412735009f0c8b8f56eb6' + temporary-token-expire-period: 900000 + access-token-expire-period: 1209600000 cloud: aws: @@ -14,4 +16,48 @@ cloud: static: '{cipher}9c1fddf30981e4a52bb3b5d483faf3640277ea98e56cd896c4af07fc74f8a7d1' s3: bucket: '{cipher}791ddfe9003ecb7c329f694bbc05d386cf783189f0e7196d5a3dcbfec48d84bd' - url: '{cipher}0d5e7427241949e0656366472edde113f98d7b215ef6f982cbf6725264510beffa4da0f650b56d13c71eea6b5827179650b9aaf563bda7a0d6a14375ff7d8eff6a603b1063687a88b2073d3ca273d1f9' \ No newline at end of file + url: '{cipher}0d5e7427241949e0656366472edde113f98d7b215ef6f982cbf6725264510beffa4da0f650b56d13c71eea6b5827179650b9aaf563bda7a0d6a14375ff7d8eff6a603b1063687a88b2073d3ca273d1f9' + profile: + default: + url: '{cipher}cb5a8d7a800e3b5558a7ad39e592f88dd22a6f81d5143d3571e51f1ac690f96f639280d2fe55c13d8ecfb0f5e91339812d41df429df17b6b9e65b33e644554719f03e300c1d9cde8776b463c3daeea1d10af9c563addf7d4ae74801d721fdc48' + +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/user?useSSL=false&serverTimezone=Asia/Seoul + username: '{cipher}56d454dea6f1332e8d9b5656400700369e6024c027fbc51e372dd64ff7ecb6a4' + password: '{cipher}1e4d1a106597d4b9efd8a80f25ccb4cec28a08b2f02d9b4c3b1ff3ffae90ad83' + hikari: + pool-name: '{cipher}b5848c642d7a307f92f1579de1809bcd68584ddbd8a474cc1d2ddef834c093e9' + maximum-pool-size: 5 + data-source-properties: + rewriteBatchedStatements: true + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: '{cipher}623b0560db7816d2ffd55845e924030f6acdc6d044abce0a1826e873e268b17d1171767fd2e21614d47fd6cdd9f07ff0cd2a39d7c956e179ad7e850a8a375292' + default_batch_fetch_size: 100 + jdbc.batch_size: 20 + order_inserts: true + order_updates: true + format_sql: true + open-in-view: false + + mail: + host: smtp.gmail.com + port: 587 + username: '{cipher}55c4145543a06549c17f7ae1a9fc1ff241cf5b04a6b12596c5585d4163826c0fd5de29fff4acd199bf7d4a03f5cad596' + password: '{cipher}d821571e7ad6e5c3aa5c0bd2353b1e79bc93f0940d22a8763616e504b7bd870627e0b18d3fba6e330771f8732cfbad8b' + properties: + mail: + smtp: + auth: true + connectiontimeout: 5000 + timeout: 5000 + starttls: + enable: true + auth-code-expiration-millis: 300000 \ No newline at end of file diff --git a/src/backend/apigateway-server/src/main/java/com/asyncgate/apigatewayserver/filter/AuthorizationHeaderFilter.java b/src/backend/apigateway-server/src/main/java/com/asyncgate/apigatewayserver/filter/AuthorizationHeaderFilter.java index ed470e77..584165a3 100644 --- a/src/backend/apigateway-server/src/main/java/com/asyncgate/apigatewayserver/filter/AuthorizationHeaderFilter.java +++ b/src/backend/apigateway-server/src/main/java/com/asyncgate/apigatewayserver/filter/AuthorizationHeaderFilter.java @@ -6,6 +6,8 @@ import com.asyncgate.apigatewayserver.exception.FailType; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; +import java.util.List; + import lombok.extern.slf4j.Slf4j; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; @@ -25,6 +27,10 @@ public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory NO_NEED_URLS = List.of( + "/sign-up", "/sign-in", "/validation/email", "/validation/authentication-code" + ); + public AuthorizationHeaderFilter( final JwtTokenProvider jwtTokenProvider, final ObjectMapper objectMapper @@ -38,6 +44,12 @@ public AuthorizationHeaderFilter( public GatewayFilter apply(final Config config) { return (exchange, chain) -> { ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + + // user server 특정 url인 경우 filter 제외 + if (NO_NEED_URLS.contains(path)) { + return chain.filter(exchange); + } if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) { return onError(exchange, FailType.AUTHORIZATION_MISSING_HEADER); diff --git a/src/backend/apigateway-server/src/main/resources/application.yml b/src/backend/apigateway-server/src/main/resources/application.yml index a3685dce..19ae6d1c 100644 --- a/src/backend/apigateway-server/src/main/resources/application.yml +++ b/src/backend/apigateway-server/src/main/resources/application.yml @@ -20,6 +20,49 @@ spring: - RemoveRequestHeader=Cookie - RewritePath=/user-service/(?.*), /$\{segment} - AuthorizationHeaderFilter + # ToDo 유저 서버 개발 후 활성화 + - id: user-server + uri: lb://USER-SERVER + predicates: + - Path=/user/sign-up + filters: + - RemoveRequestHeader=Cookie + - RewritePath=/user/(?.*), /$\{segment} + + - id: user-server + uri: lb://USER-SERVER + predicates: + - Path=/user/sign-in + filters: + - RemoveRequestHeader=Cookie + - RewritePath=/user/(?.*), /$\{segment} + + - id: user-server + uri: lb://USER-SERVER + predicates: + - Path=/user/validation/authentication-code + filters: + - RemoveRequestHeader=Cookie + - RewritePath=/user/(?.*), /$\{segment} + + - id: user-server + uri: lb://USER-SERVER + predicates: + - Path=/user/validation/email + filters: + - RemoveRequestHeader=Cookie + - RewritePath=/user/(?.*), /$\{segment} + + - id: user-server + uri: lb://USER-SERVER + predicates: + - Path=/user/** + filters: + - RemoveRequestHeader=Cookie + - RewritePath=/user/(?.*), /$\{segment} + - AuthorizationHeaderFilter + + management: endpoints: web: diff --git a/src/backend/config-server/src/main/resources/application.yml b/src/backend/config-server/src/main/resources/application.yml index 3db8bf91..21e8c600 100644 --- a/src/backend/config-server/src/main/resources/application.yml +++ b/src/backend/config-server/src/main/resources/application.yml @@ -12,9 +12,9 @@ spring: git: uri: https://github.com/sgdevcamp2025/asyncgate # ToDo 테스트 후 변경 -# default-label: be/feat/5-s3 +# default-label: be/feat/10-user # default-label: dev - default-label: main + default-label: main search-paths: - "config" diff --git a/src/backend/user-server/build.gradle b/src/backend/user-server/build.gradle index 5f1c57ce..c3c0c5c4 100644 --- a/src/backend/user-server/build.gradle +++ b/src/backend/user-server/build.gradle @@ -30,7 +30,7 @@ ext { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' -// implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.cloud:spring-cloud-starter' @@ -46,8 +46,28 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-oauth2-client' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.0' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Spring Boot Mail + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + // AWS implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // mysql + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } dependencyManagement { diff --git a/src/backend/user-server/http/UserControllerHttpRequest.http b/src/backend/user-server/http/UserControllerHttpRequest.http new file mode 100644 index 00000000..a59e1d6a --- /dev/null +++ b/src/backend/user-server/http/UserControllerHttpRequest.http @@ -0,0 +1,86 @@ +### 0.0 health check +// @no-log +GET {{host_url}}/health + +### 1.0 임시 회원가입 +// @no-log +POST {{host_url}}/sign-up +Content-Type: application/json + +{ + "email": "{{user.API_1_0_SIGNUP.email}}", + "password": "{{user.API_1_0_SIGNUP.password}}", + "name": "{{user.API_1_0_SIGNUP.name}}", + "nickname": "{{user.API_1_0_SIGNUP.nickname}}", + "birth": "{{user.API_1_0_SIGNUP.birth}}" +} + + +### 1.1 로그인 +// @no-log +POST {{host_url}}/sign-in +Content-Type: application/json + +{ + "email": "{{user.API_1_1_SIGNIN.email}}", + "password": "{{user.API_1_1_SIGNIN.password}}" +} + +> {% + client.global.set("access_token", response.body.result.access_token); +%} + + +### 1.2 인증코드 인증 +// @no-log +POST {{host_url}}/validation/authentication-code +Content-Type: application/json + +{ + "email": "{{user.API_1_2_AUTHENTICATION_CODE.email}}", + "authentication_code": "{{user.API_1_2_AUTHENTICATION_CODE.authentication_code}}" +} + +### 1.3 이메일 중복 검사 +// @no-log +POST {{host_url}}/validation/email?email={{user.API_1_3_DUPLICATE_EMAIL.email}} + +### 1.4 디바이스 토큰 등록 +// @no-log +PATCH {{host_url}}/device-token +Authorization: Bearer {{access_token}} +Content-Type: application/json + +{ + "device_token": "{{user.API_1_4_UPDATE_DEVICE_TOKEN.device_token}}" +} + +### 1.5 사용자 정보 수정 +// @no-log +PATCH {{host_url}}/info +Authorization: Bearer {{access_token}} +Content-Type: multipart/form-data; boundary=boundary + +--boundary +Content-Disposition: form-data; name="profile_image"; filename="image.png" +Content-Type: image/png + +< /Users/eunji/Desktop/profile2.png + +--boundary +Content-Disposition: form-data; name="name" + +{{user.API_1_5_UPDATE_USERINFO.name}} + +--boundary +Content-Disposition: form-data; name="nickname" + +{{user.API_1_5_UPDATE_USERINFO.nickname}} + +--boundary-- + + +### 1.6 회원 탈퇴 +// @no-log +DELETE {{host_url}}/auth +Authorization: Bearer {{access_token}} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/config/BaseEntityConfig.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/config/BaseEntityConfig.java new file mode 100644 index 00000000..fa4f81ec --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/config/BaseEntityConfig.java @@ -0,0 +1,36 @@ +package com.asyncgate.user_server.config; + +import com.asyncgate.user_server.security.info.CustomUserPrincipal; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +@EnableJpaAuditing +@Configuration +public class BaseEntityConfig { + + @Bean("user-auditorProvider") + public AuditorAware auditorProvider() { + return () -> { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + return Optional.of("AnonymousNULL"); + } + + Object principal = authentication.getPrincipal(); + + if (principal instanceof CustomUserPrincipal) { + return Optional.of(((CustomUserPrincipal) principal).getId()); + } + + return Optional.of("AnonymousNOT_TYPE"); + }; + } + +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/config/CorsConfig.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/config/CorsConfig.java new file mode 100644 index 00000000..18b7f363 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/config/CorsConfig.java @@ -0,0 +1,45 @@ +package com.asyncgate.user_server.config; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.ArrayList; +import java.util.Collections; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CorsConfig { + + public static CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + //리소스를 허용 + ArrayList allowedOriginPatterns = new ArrayList<>(); + allowedOriginPatterns.add("http://localhost:5173"); // vite + allowedOriginPatterns.add("http://127.0.0.1:5173"); + configuration.setAllowedOrigins(allowedOriginPatterns); + + //허용하는 HTTP METHOD + ArrayList allowedHttpMethods = new ArrayList<>(); + allowedHttpMethods.add("GET"); + allowedHttpMethods.add("POST"); + allowedHttpMethods.add("PUT"); + allowedHttpMethods.add("PATCH"); + allowedHttpMethods.add("DELETE"); + allowedHttpMethods.add("OPTIONS"); + configuration.setAllowedMethods(allowedHttpMethods); + + configuration.setAllowedHeaders(Collections.singletonList("*")); +// configuration.setAllowedHeaders(List.of(HttpHeaders.AUTHORIZATION, HttpHeaders.CONTENT_TYPE)); + + //인증, 인가를 위한 credentials 를 TRUE로 설정 + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/config/EmailConfig.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/config/EmailConfig.java new file mode 100644 index 00000000..262c7a3d --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/config/EmailConfig.java @@ -0,0 +1,51 @@ +package com.asyncgate.user_server.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +public class EmailConfig { + @Value("${spring.mail.host}") + private String host; + + @Value("${spring.mail.port}") + private Integer port; + + @Value("${spring.mail.username}") + private String username; + + @Value("${spring.mail.password}") + private String password; + + @Value("${spring.mail.properties.mail.smtp.auth}") + private Boolean auth; + + @Value("${spring.mail.properties.mail.smtp.timeout}") + private Integer timeout; + + @Value("${spring.mail.properties.mail.smtp.starttls.enable}") + private Boolean starttls; + + @Bean + public JavaMailSender javaMailSender() { + Properties properties = new Properties(); + properties.put("mail.smtp.auth", auth); + properties.put("mail.smtp.timeout", timeout); + properties.put("mail.smtp.starttls.enable", starttls); + + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(host); + mailSender.setPort(port); + mailSender.setUsername(username); + mailSender.setPassword(password); + mailSender.setDefaultEncoding("UTF-8"); + mailSender.setJavaMailProperties(properties); + + return mailSender; + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/controller/MemberCommandController.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/controller/MemberCommandController.java new file mode 100644 index 00000000..ca2f7fe6 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/controller/MemberCommandController.java @@ -0,0 +1,105 @@ +package com.asyncgate.user_server.controller; + +import com.asyncgate.user_server.dto.request.*; +import com.asyncgate.user_server.dto.response.CheckEmailDuplicateResponse; +import com.asyncgate.user_server.dto.response.DefaultJsonWebTokenResponse; +import com.asyncgate.user_server.security.annotation.MemberID; +import com.asyncgate.user_server.support.response.SuccessResponse; +import com.asyncgate.user_server.usecase.*; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class MemberCommandController { + + private final RegisterTemporaryMemberUseCase RegisterTemporaryMemberUseCase; + private final LoginMemberUsecase LoginMemberUsecase; + private final ValidateAuthenticationCodeUseCase ValidateAuthenticationCodeUseCase; + private final CheckEmailDuplicateUseCase CheckEmailDuplicateUseCase; + private final UpdateUserInfoUseCase UpdateUserInfoUseCase; + private final DeleteUserUseCase DeleteUserUseCase; + private final UpdateDeviceTokenUseCase UpdateDeviceTokenUseCase; + + /** + * 1.0 임시 회원가입 + */ + @PostMapping("/sign-up") + public SuccessResponse signUp( + @RequestBody final RegisterTemporaryMemberRequest request + ) { + RegisterTemporaryMemberUseCase.execute(request); + return SuccessResponse.created("인증번호가 발송되었습니다."); + } + + /** + * 1.1 로그인 + */ + @PostMapping("/sign-in") + public SuccessResponse signIn( + @RequestBody final LoginMemberRequest request + ) { + return SuccessResponse.ok( + LoginMemberUsecase.execute(request) + ); + } + + /** + * 1.2 인증번호 인증 + */ + @PostMapping("/validation/authentication-code") + public SuccessResponse validateAuthentication( + @RequestBody final ValidateAuthenticationCodeRequest request + ) { + ValidateAuthenticationCodeUseCase.execute(request); + return SuccessResponse.ok("인증되었습니다."); + } + + /** + * 1.3 이메일 중복 검사 + */ + @PostMapping("/validation/email") + public SuccessResponse checkEmailDuplicate( + @RequestParam("email") final String email + ) { + return SuccessResponse.ok( + CheckEmailDuplicateUseCase.execute(email) + ); + } + + /** + * 1.4 디바이스 토큰 업데이트 + */ + @PatchMapping("/device-token") + public SuccessResponse updateDeviceToken( + @MemberID final String userId, + @RequestBody final UpdateDeviceTokenRequest request + ) { + UpdateDeviceTokenUseCase.execute(userId, request); + return SuccessResponse.ok("디바이스 토큰 업데이트 완료"); + } + + /** + * 1.5 유저 정보 수정 + */ + @PatchMapping("/info") + public SuccessResponse updateUserInfo( + @MemberID final String userId, + @RequestPart(value = "name", required = false) final String name, + @RequestPart(value = "nickname", required = false) final String nickname, + @RequestPart(value = "profile_image", required = false) final MultipartFile profileImage + ) { + UpdateUserInfoUseCase.execute(userId, name, nickname, profileImage); + return SuccessResponse.ok("유저 정보 수정 완료"); + } + + /** + * 1.6 회원탈퇴 + */ + @DeleteMapping("/auth") + public SuccessResponse deleteUser(@MemberID final String userId) { + DeleteUserUseCase.execute(userId); + return SuccessResponse.ok("회원탈퇴 완료"); + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/controller/TestQueryController.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/controller/TestQueryController.java new file mode 100644 index 00000000..7e79e50a --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/controller/TestQueryController.java @@ -0,0 +1,19 @@ +package com.asyncgate.user_server.controller; + +import com.asyncgate.user_server.support.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class TestQueryController { + + /** + * 0.0 security no need 테스트용 get method + */ + @GetMapping("/health") + public SuccessResponse health() { + return SuccessResponse.ok("security no need"); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/AuthenticationCode.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/AuthenticationCode.java new file mode 100644 index 00000000..db4b491c --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/AuthenticationCode.java @@ -0,0 +1,25 @@ +package com.asyncgate.user_server.domain; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class AuthenticationCode implements Identifiable { + + private final String id; + private final String code; + + @Builder + private AuthenticationCode(final String id, final String code) { + this.id = id; + this.code = code; + } + + public static AuthenticationCode create(final String email, final String code) { + return new AuthenticationCode(email, code); + } + + public boolean isValid(final String inputCode) { + return this.code.equals(inputCode); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/Identifiable.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/Identifiable.java new file mode 100644 index 00000000..e0636819 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/Identifiable.java @@ -0,0 +1,5 @@ +package com.asyncgate.user_server.domain; + +public interface Identifiable { + String getId(); +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/Member.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/Member.java new file mode 100644 index 00000000..a0f1c46c --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/Member.java @@ -0,0 +1,56 @@ +package com.asyncgate.user_server.domain; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.UUID; + +@Getter +public class Member implements Identifiable { + private final String id; + private final String email; + private String password; + private String deviceToken; + private String name; + private String nickname; + private String profileImgUrl; + private LocalDate birth; + + @Builder + private Member(final String id, final String email, final String password, final String name, final String nickname, final String profileImgUrl, final String deviceToken, final LocalDate birth) { + this.id = id; + this.email = email; + this.password = password; + this.name = name; + this.nickname = nickname; + this.profileImgUrl = profileImgUrl; + this.deviceToken = deviceToken; + this.birth = birth; + } + + public static Member create(final String email, final String password, final String name, final String nickname, final String deviceToken, final String profileImgUrl, final LocalDate birth) { + String id = UUID.randomUUID().toString(); + return new Member(id, email, password, name, nickname, profileImgUrl, deviceToken, birth); + } + + public void update(String name, String nickname, String profileImageUrl) { + this.name = name; + this.nickname = nickname; + this.profileImgUrl = profileImageUrl; + + } + + public void updatePassword(String password) { + this.password = password; + } + + public void updateDeviceToken(String deviceToken) { + this.deviceToken = deviceToken; + } + + // device token이 NULL인 겨우 + public String getDeviceToken() { + return (deviceToken != null) ? deviceToken : "NO_DEVICE"; + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/TemporaryMember.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/TemporaryMember.java new file mode 100644 index 00000000..ba9fa4e3 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/domain/TemporaryMember.java @@ -0,0 +1,43 @@ +package com.asyncgate.user_server.domain; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.UUID; + +@Getter +public class TemporaryMember implements Identifiable { + + private final String id; + private final String email; + private String password; + private final String name; + private final String nickname; + private final String deviceToken; + private final LocalDate birth; + + @Builder + public TemporaryMember(final String id, final String email, final String password, final String name, final String nickname, final String deviceToken, final LocalDate birth) { + this.id = id; + this.email = email; + this.password = password; + this.name = name; + this.nickname = nickname; + this.deviceToken = deviceToken; + this.birth = birth; + } + + public static TemporaryMember create(final String email, final String password, final String name, final String nickname, final LocalDate birth) { + String id = UUID.randomUUID().toString(); + return new TemporaryMember(id, email, password, name, nickname, null, birth); + } + + /** + * 비밀번호 암호화 처리 + */ + public void encodePassword(final PasswordEncoder passwordEncoder) { + this.password = passwordEncoder.encode(this.password); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/LoginMemberRequest.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/LoginMemberRequest.java new file mode 100644 index 00000000..a10bbbe1 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/LoginMemberRequest.java @@ -0,0 +1,25 @@ +package com.asyncgate.user_server.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record LoginMemberRequest( + + @JsonProperty("email") + @NotBlank(message = "이메일을 입력해주세요.") + @Pattern( + regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$", + message = "잘못된 이메일 형식입니다." + ) + String email, + + @JsonProperty("password") + @NotBlank(message = "비밀번호를 입력해주세요.") + @Pattern( + regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$", + message = "비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다." + ) + String password +) { +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/RegisterTemporaryMemberRequest.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/RegisterTemporaryMemberRequest.java new file mode 100644 index 00000000..84e1a4f0 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/RegisterTemporaryMemberRequest.java @@ -0,0 +1,38 @@ +package com.asyncgate.user_server.dto.request; + +import jakarta.validation.constraints.NotBlank; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDate; + +public record RegisterTemporaryMemberRequest( + + @JsonProperty("email") + @NotBlank(message = "이메일을 입력해주세요.") + @Pattern( + regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$", + message = "잘못된 이메일 형식입니다." + ) + String email, + + @JsonProperty("password") + @NotBlank(message = "비밀번호를 입력해주세요.") + @Pattern( + regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$", + message = "비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다." + ) + String password, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + @NotBlank(message = "닉네임을 입력해주세요.") + String nickname, + + @JsonProperty("birth") + @NotBlank(message = "생년월일을 입력해주세요.") + LocalDate birth +) { +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/UpdateDeviceTokenRequest.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/UpdateDeviceTokenRequest.java new file mode 100644 index 00000000..d2c2ca19 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/UpdateDeviceTokenRequest.java @@ -0,0 +1,10 @@ +package com.asyncgate.user_server.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record UpdateDeviceTokenRequest( + + @JsonProperty("device_token") + String deviceToken +) { +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/ValidateAuthenticationCodeRequest.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/ValidateAuthenticationCodeRequest.java new file mode 100644 index 00000000..5e24bcb9 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/request/ValidateAuthenticationCodeRequest.java @@ -0,0 +1,19 @@ +package com.asyncgate.user_server.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ValidateAuthenticationCodeRequest( + + @JsonProperty("email") + @NotBlank(message = "이메일을 입력해주세요.") + @Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$", message = "잘못된 이메일 형식입니다.") + String email, + + @JsonProperty("authentication_code") + @NotBlank(message = "인증 코드를 입력해주세요.") + @Pattern(regexp = "^[0-9]{6}$", message = "인증 코드는 6자리의 숫자로 이루어져 있습니다.") + String authenticationCode +) { +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/response/CheckEmailDuplicateResponse.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/response/CheckEmailDuplicateResponse.java new file mode 100644 index 00000000..62ed7e5e --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/response/CheckEmailDuplicateResponse.java @@ -0,0 +1,17 @@ +package com.asyncgate.user_server.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +public class CheckEmailDuplicateResponse { + + @JsonProperty("is_duplicate") + @NotBlank + private final boolean isDuplicate; + + @Builder + public CheckEmailDuplicateResponse(boolean isDuplicate) { + this.isDuplicate = isDuplicate; + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/response/DefaultJsonWebTokenResponse.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/response/DefaultJsonWebTokenResponse.java new file mode 100644 index 00000000..70be5593 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/dto/response/DefaultJsonWebTokenResponse.java @@ -0,0 +1,21 @@ +package com.asyncgate.user_server.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class DefaultJsonWebTokenResponse { + + @JsonProperty("access_token") + @NotBlank + private final String accessToken; + + @Builder + public DefaultJsonWebTokenResponse( + String accessToken + ) { + this.accessToken = accessToken; + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/MemberEntity.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/MemberEntity.java new file mode 100644 index 00000000..5f8dd44d --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/MemberEntity.java @@ -0,0 +1,67 @@ +package com.asyncgate.user_server.entity; + +import com.asyncgate.user_server.entity.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "member") +public class MemberEntity extends BaseEntity { + + /* -------------------------------------------- */ + /* Default Column ----------------------------- */ + /* -------------------------------------------- */ + @Id + @Column(name = "id") + private String id; + + /* -------------------------------------------- */ + /* Security Column ---------------------------- */ + /* -------------------------------------------- */ + @Column(name = "email", length = 100, nullable = false, updatable = false) + private String email; + + @Column(name = "password", length = 320, nullable = false) + private String password; + + @Column(name = "device_token", length = 320) + private String deviceToken; + + /* -------------------------------------------- */ + /* Information Column ------------------------- */ + /* -------------------------------------------- */ + @Column(name = "name", length = 100, nullable = false) + private String name; + + @Column(name = "nickname", length = 100) + private String nickname; + + @Column(name = "profile_img_url", length = 320) + private String profileImgUrl; + + @Column(name = "birth", nullable = false) + private LocalDate birth; + + /* -------------------------------------------- */ + /* Methods ------------------------------------ */ + /* -------------------------------------------- */ + @Builder + private MemberEntity(String id, String email, String password, String deviceToken, String name, String nickname, String profileImgUrl, LocalDate birth) { + this.id = id; + this.email = email; + this.password = password; + this.deviceToken = deviceToken; + this.name = name; + this.nickname = nickname; + this.profileImgUrl = profileImgUrl; + this.birth = birth; + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/common/BaseEntity.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/common/BaseEntity.java new file mode 100644 index 00000000..ade6fc1c --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/common/BaseEntity.java @@ -0,0 +1,38 @@ +package com.asyncgate.user_server.entity.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity extends BaseTimeEntity { + + @CreatedBy + @Column(updatable = false) + private String createdBy; + + @LastModifiedBy + private String lastModifiedBy; + + private boolean deleted; + + // 재활성화 - soft delete + public void activate() { + this.deleted = false; + } + + // 비활성화 - soft delete + public void deactivate() { + this.deleted = true; + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/common/BaseTimeEntity.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/common/BaseTimeEntity.java new file mode 100644 index 00000000..22c045da --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/common/BaseTimeEntity.java @@ -0,0 +1,25 @@ +package com.asyncgate.user_server.entity.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime lastModifiedDate; + +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/redis/AuthenticationCodeEntity.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/redis/AuthenticationCodeEntity.java new file mode 100644 index 00000000..0b5deb98 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/redis/AuthenticationCodeEntity.java @@ -0,0 +1,25 @@ +package com.asyncgate.user_server.entity.redis; + +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.redis.core.RedisHash; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@RedisHash(value = "auth_code", timeToLive = 60 * 5) // 5분 동안 유지 +public class AuthenticationCodeEntity { + + @Id + private String id; + + private String code; + + @Builder + private AuthenticationCodeEntity(String id, String code) { + this.id = id; + this.code = code; + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/redis/TemporaryMemberEntity.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/redis/TemporaryMemberEntity.java new file mode 100644 index 00000000..73620e8c --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/entity/redis/TemporaryMemberEntity.java @@ -0,0 +1,40 @@ +package com.asyncgate.user_server.entity.redis; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +import java.time.LocalDate; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@RedisHash(value = "temporary_account", timeToLive = 60 * 30) // 30분 +public class TemporaryMemberEntity { + + @Id + private String id; + + @Indexed + private String email; + + private String password; + private String name; + private String nickname; + private String deviceToken; + private LocalDate birth; + + @Builder + private TemporaryMemberEntity(String id, String email, String password, String name, String nickname, String deviceToken, LocalDate birth) { + this.id = id; + this.email = email; + this.password = password; + this.name = name; + this.nickname = nickname; + this.deviceToken = deviceToken; + this.birth = birth; + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/exception/FailType.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/exception/FailType.java index 7cce5f1b..65387e3e 100644 --- a/src/backend/user-server/src/main/java/com/asyncgate/user_server/exception/FailType.java +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/exception/FailType.java @@ -12,11 +12,41 @@ public enum FailType { // 알 수 없는 에러 _UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Server_5000", "알 수 없는 에러가 발생하였습니다."), + _CONCURRENT_UPDATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Server_5001", "동시 업데이트 에러가 발생하였습니다."), // S3 _UPLOAD_FILE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3_5001", "S3 이미지 업로드에 실패하였습니다."), _DELETE_FILE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3_5002", "S3 이미지 제거에 실패하였습니다."), - _FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "S3_5003", "파일이 S3에 존재하지 않습니다."); + _FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "S3_5003", "파일이 S3에 존재하지 않습니다."), + + // email + _SEND_EMAIL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Email_5000", "이메일 전송에 실패하였습니다."), + + INVALID_EMAIL_AUTH_CODE(HttpStatus.BAD_REQUEST, "Email_4001", "인증 코드가 일치하지 않습니다."), + EMAIL_AUTH_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "Email_4002", "인증 코드가 만료되었습니다."), + EMAIL_AUTH_CODE_SEND(HttpStatus.BAD_REQUEST, "Email_4003", "인증 코드 전송에 실패하였습니다."), + EMAIL_AUTH_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "Email_4004", "인증 코드가 존재하지 않습니다."), + + // password + _INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "Password_4001", "비밀번호가 일치하지 않습니다."), + _INVALID_PASSWORD_FORMAT(HttpStatus.BAD_REQUEST, "Password_4002", "비밀번호 형식이 올바르지 않습니다."), + + // temporary member + TEMPORARY_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "TemporaryMember_4001", "임시 회원 정보가 존재하지 않습니다."), + + // member + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "Member_4001", "회원 정보가 존재하지 않습니다."), + MEMBER_NOT_EXIST_EMAIL(HttpStatus.NOT_FOUND, "Member_4002", "존재하지 않는 이메일입니다."), + + // Unauthorized Error + INVALID_HEADER_ERROR(HttpStatus.UNAUTHORIZED, "Member_40108", "헤더가 올바르지 않습니다."), + + // Register member + ALREADY_EXIST_EMAIL(HttpStatus.BAD_REQUEST, "Member_40200", "이미 존재하는 이메일입니다."), + + // Access Denied Error + ACCESS_DENIED(HttpStatus.FORBIDDEN, "Access_40300", "접근 권한이 없습니다."), + NOT_LOGIN_USER(HttpStatus.FORBIDDEN, "Access_40301", "로그인하지 않은 사용자입니다."); private final HttpStatus status; private final String errorCode; diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/MemberJpaRepository.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/MemberJpaRepository.java new file mode 100644 index 00000000..586561ed --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/MemberJpaRepository.java @@ -0,0 +1,22 @@ +package com.asyncgate.user_server.repository; + +import com.asyncgate.user_server.entity.MemberEntity; +import org.springframework.data.repository.query.Param; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + @Modifying + @Query("UPDATE MemberEntity m SET m.deleted = true WHERE m.id = :id") + void softDeleteById(@Param("id") String id); + + @Query("SELECT m FROM MemberEntity m WHERE m.id = :id AND m.deleted = false") + Optional findByNotDeletedId(@Param("id") String id); + + @Query("SELECT m FROM MemberEntity m WHERE m.email = :email AND m.deleted = false") + Optional findByNotDeletedEmail(@Param("email") String email); +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/MemberRepository.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/MemberRepository.java new file mode 100644 index 00000000..509b81a9 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/MemberRepository.java @@ -0,0 +1,19 @@ +package com.asyncgate.user_server.repository; + +import com.asyncgate.user_server.domain.Member; + +import java.util.Optional; + +public interface MemberRepository { + Optional findByEmail(String email); + + Optional findById(String id); + + boolean isExistByEmail(String email); + + boolean isExistById(String id); + + void save(Member member); + + void softDeleteById(String id); +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/MemberRepositoryImpl.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/MemberRepositoryImpl.java new file mode 100644 index 00000000..74ece7c9 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/MemberRepositoryImpl.java @@ -0,0 +1,64 @@ +package com.asyncgate.user_server.repository; + +import com.asyncgate.user_server.domain.Member; +import com.asyncgate.user_server.entity.MemberEntity; +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.exception.UserServerException; +import com.asyncgate.user_server.support.utility.DomainUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class MemberRepositoryImpl implements MemberRepository { + private final MemberJpaRepository memberJpaRepository; + + @Override + public void save(final Member member) { + MemberEntity memberEntity = DomainUtil.MemberMapper.toEntity(member); + + memberJpaRepository.save(memberEntity); + } + + @Override + public void softDeleteById(final String id) { + + memberJpaRepository.softDeleteById(id); + } + + @Override + public Optional findByEmail(final String email) { + return memberJpaRepository.findByNotDeletedEmail(email) + .map(DomainUtil.MemberMapper::toDomain); + } + + @Override + public Optional findById(final String id) { + return memberJpaRepository.findByNotDeletedId(id) + .map(DomainUtil.MemberMapper::toDomain); + } + + @Override + public boolean isExistByEmail(final String email) { + return memberJpaRepository.findByNotDeletedEmail(email).isPresent(); + } + + @Override + public boolean isExistById(final String id) { + return memberJpaRepository.findById(id).isPresent(); + } + + private MemberEntity findMemberEntityByEmail(final String email) { + return memberJpaRepository.findByNotDeletedEmail(email) + .orElseThrow(() -> new UserServerException(FailType.MEMBER_NOT_EXIST_EMAIL)); + } + + private MemberEntity findMemberEntityById(final String id) { + return memberJpaRepository.findByNotDeletedId(id) + .orElseThrow(() -> new UserServerException(FailType.MEMBER_NOT_FOUND)); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/AuthenticationCodeCrudRepository.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/AuthenticationCodeCrudRepository.java new file mode 100644 index 00000000..f1d732fa --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/AuthenticationCodeCrudRepository.java @@ -0,0 +1,7 @@ +package com.asyncgate.user_server.repository.redis; + +import com.asyncgate.user_server.entity.redis.AuthenticationCodeEntity; +import org.springframework.data.repository.CrudRepository; + +public interface AuthenticationCodeCrudRepository extends CrudRepository { +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/AuthenticationCodeRepository.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/AuthenticationCodeRepository.java new file mode 100644 index 00000000..0e89aae3 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/AuthenticationCodeRepository.java @@ -0,0 +1,13 @@ +package com.asyncgate.user_server.repository.redis; + +import com.asyncgate.user_server.entity.redis.AuthenticationCodeEntity; +import org.springframework.data.repository.CrudRepository; + + +public interface AuthenticationCodeRepository { + AuthenticationCodeEntity save(AuthenticationCodeEntity authenticationCodeEntity); + + AuthenticationCodeEntity findById(String id); + + void delete(AuthenticationCodeEntity authenticationCodeEntity); +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/AuthenticationCodeRepositoryImpl.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/AuthenticationCodeRepositoryImpl.java new file mode 100644 index 00000000..0a83776e --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/AuthenticationCodeRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.asyncgate.user_server.repository.redis; + +import com.asyncgate.user_server.entity.redis.AuthenticationCodeEntity; +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.exception.UserServerException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class AuthenticationCodeRepositoryImpl implements AuthenticationCodeRepository { + private final AuthenticationCodeCrudRepository authenticationCodeCrudRepository; + + @Override + public AuthenticationCodeEntity save(AuthenticationCodeEntity authenticationCodeEntity) { + return authenticationCodeCrudRepository.save(authenticationCodeEntity); + } + + @Override + public void delete(AuthenticationCodeEntity authenticationCodeEntity) { + authenticationCodeCrudRepository.delete(authenticationCodeEntity); + } + + @Override + public AuthenticationCodeEntity findById(String id) { + return authenticationCodeCrudRepository.findById(id) + .orElseThrow(() -> new UserServerException(FailType.EMAIL_AUTH_CODE_NOT_FOUND)); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/TemporaryMemberCrudRepository.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/TemporaryMemberCrudRepository.java new file mode 100644 index 00000000..2e1b299a --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/TemporaryMemberCrudRepository.java @@ -0,0 +1,10 @@ +package com.asyncgate.user_server.repository.redis; + +import com.asyncgate.user_server.entity.redis.TemporaryMemberEntity; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface TemporaryMemberCrudRepository extends CrudRepository { + Optional findByEmail(String email); +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/TemporaryMemberRepository.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/TemporaryMemberRepository.java new file mode 100644 index 00000000..f32f75e4 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/TemporaryMemberRepository.java @@ -0,0 +1,14 @@ +package com.asyncgate.user_server.repository.redis; + +import com.asyncgate.user_server.domain.Member; +import com.asyncgate.user_server.entity.redis.TemporaryMemberEntity; + +import java.util.Optional; + +public interface TemporaryMemberRepository { + Optional findByEmail(String email); + + void delete(TemporaryMemberEntity temporaryMemberEntity); + + void save(TemporaryMemberEntity temporaryMemberEntity); +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/TemporaryMemberRepositoryImpl.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/TemporaryMemberRepositoryImpl.java new file mode 100644 index 00000000..a1ec703c --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/repository/redis/TemporaryMemberRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.asyncgate.user_server.repository.redis; + +import com.asyncgate.user_server.entity.redis.TemporaryMemberEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class TemporaryMemberRepositoryImpl implements TemporaryMemberRepository { + private final TemporaryMemberCrudRepository temporaryMemberCrudRepository; + + @Override + public void save(TemporaryMemberEntity temporaryMemberEntity) { + temporaryMemberCrudRepository.save(temporaryMemberEntity); + } + + @Override + public Optional findByEmail(String email) { + return temporaryMemberCrudRepository.findByEmail(email); + } + + + @Override + public void delete(TemporaryMemberEntity temporaryMemberEntity) { + temporaryMemberCrudRepository.delete(temporaryMemberEntity); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/annotation/MemberID.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/annotation/MemberID.java new file mode 100644 index 00000000..69fe8f9a --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/annotation/MemberID.java @@ -0,0 +1,9 @@ +package com.asyncgate.user_server.security.annotation; + +import java.lang.annotation.*; + +@Documented +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface MemberID { +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/BCryptConfig.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/BCryptConfig.java new file mode 100644 index 00000000..fabc9a09 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/BCryptConfig.java @@ -0,0 +1,15 @@ +package com.asyncgate.user_server.security.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class BCryptConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/SecurityConfig.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/SecurityConfig.java new file mode 100644 index 00000000..be95e45e --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/SecurityConfig.java @@ -0,0 +1,57 @@ +package com.asyncgate.user_server.security.config; + +import com.asyncgate.user_server.config.CorsConfig; +import com.asyncgate.user_server.security.constant.Constants; +import com.asyncgate.user_server.security.filter.JsonWebTokenAuthenticationFilter; +import com.asyncgate.user_server.security.usecase.AuthenticateJsonWebTokenUseCase; +import com.asyncgate.user_server.security.utility.JsonWebTokenUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final AuthenticateJsonWebTokenUseCase authenticateJsonWebTokenUseCase; + + private final JsonWebTokenUtil jsonWebTokenUtil; + + @Bean + protected SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { + return httpSecurity + .cors(cors -> cors + .configurationSource(CorsConfig.corsConfigurationSource()) + ) + .csrf(AbstractHttpConfigurer::disable) + + .httpBasic(AbstractHttpConfigurer::disable) + + .sessionManagement(configurer -> configurer + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + .authorizeHttpRequests(configurer -> configurer + .requestMatchers(Constants.NO_NEED_AUTH_URLS.toArray(String[]::new)).permitAll() + .anyRequest().authenticated() + ) + + // 빈 주입 + .addFilterBefore( + new JsonWebTokenAuthenticationFilter( + authenticateJsonWebTokenUseCase, + jsonWebTokenUtil + ), + LogoutFilter.class + ) + + .getOrBuild(); + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/WebConfig.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/WebConfig.java new file mode 100644 index 00000000..1ba33e07 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/WebConfig.java @@ -0,0 +1,23 @@ +package com.asyncgate.user_server.security.config; + +import com.asyncgate.user_server.security.resolver.HttpMemberIDArgumentResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final HttpMemberIDArgumentResolver memberIDArgumentResolver; + + public WebConfig(HttpMemberIDArgumentResolver memberIDArgumentResolver) { + this.memberIDArgumentResolver = memberIDArgumentResolver; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(memberIDArgumentResolver); + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/constant/Constants.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/constant/Constants.java new file mode 100644 index 00000000..d6bc92c9 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/constant/Constants.java @@ -0,0 +1,46 @@ +package com.asyncgate.user_server.security.constant; + +import java.util.List; + +public class Constants { + + // JWT + public static String MEMBER_ID_ATTRIBUTE_NAME = "MEMBER_ID"; + public static String MEMBER_ID_CLAIM_NAME = "mid"; + + // HEADER + public static String BEARER_PREFIX = "Bearer "; + public static String AUTHORIZATION_HEADER = "Authorization"; + + + /** + * 인증이 필요 없는 URL + */ + public static List NO_NEED_AUTH_URLS = List.of( + // Authentication/Authorization + "/connection-test", + "/validation/authentication-code", + "/sign-up", + "/sign-in", + "/validation/email", + "/", // root + "/actuator/info", + "/health", + + // Swagger + "/api-docs.html", + "/api-docs/**", + "/swagger-ui/**", + "/v3/**" + ); + + /** + * Swagger 에서 사용하는 URL + */ + public static List SWAGGER_URLS = List.of( + "/api-docs.html", + "/api-docs", + "/swagger-ui", + "/v3" + ); +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/exception/CommonException.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/exception/CommonException.java new file mode 100644 index 00000000..4ca4d100 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/exception/CommonException.java @@ -0,0 +1,16 @@ +package com.asyncgate.user_server.security.exception; + +// 각 application에 맞는 failType으로 정의해주세요 ! +import com.asyncgate.user_server.exception.FailType; +import lombok.Getter; + +@Getter +public class CommonException extends RuntimeException { + + private final FailType failType; + + public CommonException(FailType failType) { + super(failType.getMessage()); + this.failType = failType; + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/exception/SecurityException.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/exception/SecurityException.java new file mode 100644 index 00000000..a7dfc8e9 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/exception/SecurityException.java @@ -0,0 +1,16 @@ +package com.asyncgate.user_server.security.exception; + +import com.asyncgate.user_server.exception.FailType; +import io.jsonwebtoken.JwtException; +import lombok.Getter; + +@Getter +public class SecurityException extends JwtException { + private final FailType failType; + + public SecurityException(String message, FailType failType) { + super(message); + + this.failType = failType; + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/filter/JsonWebTokenAuthenticationFilter.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/filter/JsonWebTokenAuthenticationFilter.java new file mode 100644 index 00000000..e239feb0 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/filter/JsonWebTokenAuthenticationFilter.java @@ -0,0 +1,79 @@ +package com.asyncgate.user_server.security.filter; + +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.security.usecase.AuthenticateJsonWebTokenUseCase; +import com.asyncgate.user_server.security.constant.Constants; +import com.asyncgate.user_server.security.exception.CommonException; +import com.asyncgate.user_server.security.info.CustomUserPrincipal; +import com.asyncgate.user_server.security.utility.HeaderUtil; +import com.asyncgate.user_server.security.utility.JsonWebTokenUtil; +import io.jsonwebtoken.*; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT를 이용한 인증을 처리하는 필터 + */ +@RequiredArgsConstructor +public class JsonWebTokenAuthenticationFilter extends OncePerRequestFilter { + + private final AuthenticateJsonWebTokenUseCase authenticateJsonWebTokenUseCase; + + private final JsonWebTokenUtil jsonWebTokenUtil; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = HeaderUtil.refineHeader(request, Constants.AUTHORIZATION_HEADER, Constants.BEARER_PREFIX) + .orElseThrow(() -> new CommonException(FailType.INVALID_HEADER_ERROR)); + + Claims claims = jsonWebTokenUtil.validate(token); + + String memberId = claims.get(Constants.MEMBER_ID_CLAIM_NAME, String.class); + + CustomUserPrincipal principal = authenticateJsonWebTokenUseCase.execute(memberId); + + // AuthenticationToken 생성 + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + principal.getAuthorities() + ); + + // SecurityContext에 AuthenticationToken 저장 + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authenticationToken); + SecurityContextHolder.setContext(context); + + // memberId를 request에 추가 + request.setAttribute(Constants.MEMBER_ID_ATTRIBUTE_NAME, memberId); + + // 다음 필터로 전달 + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String requestURI = request.getRequestURI(); + + // 인증이 필요 없는 URL 목록에 포함되는지 확인 + return Constants.NO_NEED_AUTH_URLS.stream() + .anyMatch(excludePattern -> requestURI.matches(excludePattern.replace("**", ".*"))); + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/info/CustomUserPrincipal.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/info/CustomUserPrincipal.java new file mode 100644 index 00000000..4877ea3e --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/info/CustomUserPrincipal.java @@ -0,0 +1,71 @@ +package com.asyncgate.user_server.security.info; + +import com.asyncgate.user_server.entity.MemberEntity; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * Spring Security에서 사용하는 UserDetails를 구현한 클래스 + */ +@Builder +@RequiredArgsConstructor +public class CustomUserPrincipal implements UserDetails { + + @Getter + private final String id; + @Getter + private final String email; + private final String password; + + public static CustomUserPrincipal create(MemberEntity member) { + return CustomUserPrincipal.builder() + .id(member.getId()) + .email(member.getEmail()) + .password(member.getPassword()) + .build(); + } + + @Override + public String getUsername() { + return id; + } + + // 임시 권한 user + @Override + public Collection getAuthorities() { + SimpleGrantedAuthority adminAuthority = new SimpleGrantedAuthority("ROLE_USER"); + Collection authorities = new ArrayList<>(); + authorities.add(adminAuthority); + + return authorities; + } + + @Override + public String getPassword() { + return password; + } + + public boolean isAccountNonExpired() { + return true; + } + + public boolean isAccountNonLocked() { + return true; + } + + public boolean isCredentialsNonExpired() { + return true; + } + + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/resolver/HttpMemberIDArgumentResolver.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/resolver/HttpMemberIDArgumentResolver.java new file mode 100644 index 00000000..f2e616d0 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/resolver/HttpMemberIDArgumentResolver.java @@ -0,0 +1,45 @@ +package com.asyncgate.user_server.security.resolver; + +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.security.constant.Constants; +import com.asyncgate.user_server.security.annotation.MemberID; +import com.asyncgate.user_server.security.exception.CommonException; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * HTTP 요청에서 Member ID를 추출하는 HandlerMethodArgumentResolver + */ +@Component +public class HttpMemberIDArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(String.class) // 🔥 String으로 변경 + && parameter.hasParameterAnnotation(MemberID.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + Object memberId = webRequest.getAttribute( + Constants.MEMBER_ID_ATTRIBUTE_NAME, + WebRequest.SCOPE_REQUEST + ); + + if (memberId == null) { + throw new CommonException(FailType.ACCESS_DENIED); + } + + return memberId.toString(); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/service/AuthenticateJsonWebTokenService.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/service/AuthenticateJsonWebTokenService.java new file mode 100644 index 00000000..fe8500ab --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/service/AuthenticateJsonWebTokenService.java @@ -0,0 +1,25 @@ +package com.asyncgate.user_server.security.service; + +import com.asyncgate.user_server.domain.Member; +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.exception.UserServerException; +import com.asyncgate.user_server.repository.MemberRepository; +import com.asyncgate.user_server.security.info.CustomUserPrincipal; +import com.asyncgate.user_server.security.usecase.AuthenticateJsonWebTokenUseCase; +import com.asyncgate.user_server.support.utility.DomainUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthenticateJsonWebTokenService implements AuthenticateJsonWebTokenUseCase { + private final MemberRepository memberRepository; + + @Override + public CustomUserPrincipal execute(final String id) { + Member member = memberRepository.findById(id) + .orElseThrow(() -> new UserServerException(FailType.MEMBER_NOT_FOUND)); + + return CustomUserPrincipal.create(DomainUtil.MemberMapper.toEntity(member)); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/usecase/AuthenticateJsonWebTokenUseCase.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/usecase/AuthenticateJsonWebTokenUseCase.java new file mode 100644 index 00000000..fa069243 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/usecase/AuthenticateJsonWebTokenUseCase.java @@ -0,0 +1,11 @@ +package com.asyncgate.user_server.security.usecase; + + +import com.asyncgate.user_server.security.info.CustomUserPrincipal; +import org.springframework.stereotype.Component; + +@Component +public interface AuthenticateJsonWebTokenUseCase { + + CustomUserPrincipal execute(String memberId); +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/utility/HeaderUtil.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/utility/HeaderUtil.java new file mode 100644 index 00000000..be039f9c --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/utility/HeaderUtil.java @@ -0,0 +1,27 @@ +package com.asyncgate.user_server.security.utility; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +/** + * Authorization 헤더를 파싱하는 유틸리티 클래스 + */ +public class HeaderUtil { + + public static Optional refineHeader(final HttpServletRequest request, final String header, final String prefix) { + String unpreparedToken = request.getHeader(header); + + if (!StringUtils.hasText(unpreparedToken)) { + return Optional.empty(); + } + + // prefix가 존재하면 제거하고, 없으면 그대로 반환 + if (unpreparedToken.startsWith(prefix)) { + return Optional.of(unpreparedToken.substring(prefix.length())); + } + + return Optional.of(unpreparedToken); + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/utility/JsonWebTokenUtil.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/utility/JsonWebTokenUtil.java new file mode 100644 index 00000000..4faa822b --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/utility/JsonWebTokenUtil.java @@ -0,0 +1,69 @@ +package com.asyncgate.user_server.security.utility; + +import com.asyncgate.user_server.security.constant.Constants; +import com.asyncgate.user_server.dto.response.DefaultJsonWebTokenResponse; +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.exception.UserServerException; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +/** + * JWT 토큰 생성 및 검증 유틸리티 클래스 + */ +@Component +public class JsonWebTokenUtil implements InitializingBean { + @Value("${jwt.secret-key}") + private String secretKey; + + @Value("${jwt.access-token-expire-period}") + private Long accessTokenExpirePeriod; + + private Key key; + + @Override + public void afterPropertiesSet() { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + // token 생성 메서드 + public DefaultJsonWebTokenResponse generate(final String id) { + return new DefaultJsonWebTokenResponse( + generateJwt(id, accessTokenExpirePeriod) + ); + } + + // token 검증 메서드 + public Claims validate(final String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + // JWT 예외처리는 apigateway에서 처리 + } catch (Exception e) { + throw new UserServerException(FailType._UNKNOWN_ERROR); + } + } + + private String generateJwt(final String identifier, final Long expirePeriod) { + Claims claims = Jwts.claims(); + + claims.put(Constants.MEMBER_ID_CLAIM_NAME, identifier); + + return Jwts.builder() + .setHeaderParam(Header.JWT_TYPE, Header.JWT_TYPE) + .setClaims(claims) + .setSubject(identifier) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expirePeriod)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/CheckEmailDuplicateService.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/CheckEmailDuplicateService.java new file mode 100644 index 00000000..c64fc8ff --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/CheckEmailDuplicateService.java @@ -0,0 +1,26 @@ +package com.asyncgate.user_server.service; + +import com.asyncgate.user_server.dto.response.CheckEmailDuplicateResponse; +import com.asyncgate.user_server.repository.MemberRepository; +import com.asyncgate.user_server.usecase.CheckEmailDuplicateUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CheckEmailDuplicateService implements CheckEmailDuplicateUseCase { + + private final MemberRepository memberRepository; + + public CheckEmailDuplicateResponse execute(final String email) { + return CheckEmailDuplicateResponse.builder() + .isDuplicate(isDuplicatedEmail(email)) + .build(); + } + + private Boolean isDuplicatedEmail( + final String email + ) { + return memberRepository.isExistByEmail(email); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/DeleteUserService.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/DeleteUserService.java new file mode 100644 index 00000000..54051d8f --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/DeleteUserService.java @@ -0,0 +1,28 @@ +package com.asyncgate.user_server.service; + +import com.asyncgate.user_server.domain.Member; +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.exception.UserServerException; +import com.asyncgate.user_server.repository.MemberRepository; +import com.asyncgate.user_server.usecase.DeleteUserUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeleteUserService implements DeleteUserUseCase { + + private final MemberRepository memberRepository; + + @Override + @Transactional + public void execute(final String userId) { + Member member = memberRepository.findById(userId) + .orElseThrow(() -> new UserServerException(FailType.MEMBER_NOT_FOUND)); + + memberRepository.softDeleteById(member.getId()); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/LoginMemberService.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/LoginMemberService.java new file mode 100644 index 00000000..3a9f9c4f --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/LoginMemberService.java @@ -0,0 +1,41 @@ +package com.asyncgate.user_server.service; + +import com.asyncgate.user_server.domain.Member; +import com.asyncgate.user_server.dto.request.LoginMemberRequest; +import com.asyncgate.user_server.dto.response.DefaultJsonWebTokenResponse; +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.exception.UserServerException; +import com.asyncgate.user_server.repository.MemberRepository; +import com.asyncgate.user_server.security.utility.JsonWebTokenUtil; +import com.asyncgate.user_server.usecase.LoginMemberUsecase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class LoginMemberService implements LoginMemberUsecase { + private final MemberRepository memberRepository; + private final JsonWebTokenUtil jsonWebTokenUtil; + private final PasswordEncoder bCryptPasswordEncoder; + + @Override + @Transactional(readOnly = true) + public DefaultJsonWebTokenResponse execute(final LoginMemberRequest request) { + Member member = memberRepository.findByEmail(request.email()) + .orElseThrow(() -> new UserServerException(FailType.MEMBER_NOT_EXIST_EMAIL)); + + // 비밀번호 일치 인증 + if (!bCryptPasswordEncoder.matches(request.password(), member.getPassword())) { + throw new UserServerException(FailType._INVALID_PASSWORD); + } + + DefaultJsonWebTokenResponse response = jsonWebTokenUtil.generate(member.getId()); + log.info(response.getAccessToken()); + + return response; + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/RegisterTemporaryMemberService.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/RegisterTemporaryMemberService.java new file mode 100644 index 00000000..2ead126a --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/RegisterTemporaryMemberService.java @@ -0,0 +1,59 @@ +package com.asyncgate.user_server.service; + +import com.asyncgate.user_server.domain.AuthenticationCode; +import com.asyncgate.user_server.domain.TemporaryMember; +import com.asyncgate.user_server.dto.request.RegisterTemporaryMemberRequest; +import com.asyncgate.user_server.repository.redis.AuthenticationCodeRepository; +import com.asyncgate.user_server.repository.redis.TemporaryMemberRepository; +import com.asyncgate.user_server.support.utility.DomainUtil; +import com.asyncgate.user_server.support.utility.EmailUtil; +import com.asyncgate.user_server.support.utility.PasswordUtil; +import com.asyncgate.user_server.usecase.RegisterTemporaryMemberUseCase; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RegisterTemporaryMemberService implements RegisterTemporaryMemberUseCase { + + private final AuthenticationCodeRepository authenticationCodeRepository; + private final PasswordEncoder passwordEncoder; + private final TemporaryMemberRepository temporaryMemberRepository; + private final EmailUtil emailUtil; + + @Override + @Transactional + public void execute(final RegisterTemporaryMemberRequest request) { + + // 도메인 객체 생성 + TemporaryMember tempMember = TemporaryMember.builder() + .id(UUID.randomUUID().toString()) // 고유 ID 생성 + .email(request.email()) + .password(request.password()) + .name(request.name()) + .nickname(request.nickname()) + .birth(request.birth()) + .build(); + + tempMember.encodePassword(passwordEncoder); + + // 임시 계정 생성 + temporaryMemberRepository.save(DomainUtil.TemporaryMemberMapper.toEntity(tempMember)); + + String code = PasswordUtil.generateAuthCode(6); + + AuthenticationCode authCode = AuthenticationCode.create(request.email(), code); + + authenticationCodeRepository.save(DomainUtil.AuthenticationCodeMapper.toEntity(authCode)); + + // 메일 전송 (추후 비동기로 변경) + emailUtil.sendAuthenticationCode(request.email(), code); + } + +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/UpdateDeviceTokenService.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/UpdateDeviceTokenService.java new file mode 100644 index 00000000..56ce9cf3 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/UpdateDeviceTokenService.java @@ -0,0 +1,28 @@ +package com.asyncgate.user_server.service; + +import com.asyncgate.user_server.domain.Member; +import com.asyncgate.user_server.dto.request.UpdateDeviceTokenRequest; +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.exception.UserServerException; +import com.asyncgate.user_server.repository.MemberRepository; +import com.asyncgate.user_server.usecase.UpdateDeviceTokenUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UpdateDeviceTokenService implements UpdateDeviceTokenUseCase { + private final MemberRepository memberRepository; + + @Override + public void execute(final String userId, final UpdateDeviceTokenRequest request) { + Member member = memberRepository.findById(userId) + .orElseThrow(() -> new UserServerException(FailType.MEMBER_NOT_FOUND)); + + member.updateDeviceToken(request.deviceToken()); + + memberRepository.save(member); + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/UpdateUserInfoService.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/UpdateUserInfoService.java new file mode 100644 index 00000000..8b2d7c21 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/UpdateUserInfoService.java @@ -0,0 +1,47 @@ +package com.asyncgate.user_server.service; + +import com.asyncgate.user_server.domain.Member; +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.exception.UserServerException; +import com.asyncgate.user_server.repository.MemberRepository; +import com.asyncgate.user_server.support.utility.S3Util; +import com.asyncgate.user_server.usecase.UpdateUserInfoUseCase; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UpdateUserInfoService implements UpdateUserInfoUseCase { + + private final MemberRepository memberRepository; + private final S3Util s3Util; + + @Value("${cloud.aws.s3.profile.default.url}") + private String defaultProfileImageUrl; + + @Override + @Transactional + public void execute(final String userId, final String name, final String nickname, final MultipartFile profileImage) { + String profileImageUrl = getProfileImageUrl(profileImage); + + Member member = memberRepository.findById(userId) + .orElseThrow(() -> new UserServerException(FailType.MEMBER_NOT_FOUND)); + + member.update(name, nickname, profileImageUrl); + + memberRepository.save(member); + } + + private String getProfileImageUrl(final MultipartFile profileImage) { + if (profileImage != null && !profileImage.isEmpty()) { + return s3Util.uploadFile(profileImage, Member.class.getName()); + } else { + return defaultProfileImageUrl; + } + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/ValidateAuthenticationCodeService.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/ValidateAuthenticationCodeService.java new file mode 100644 index 00000000..7c1d9234 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/service/ValidateAuthenticationCodeService.java @@ -0,0 +1,54 @@ +package com.asyncgate.user_server.service; + +import com.asyncgate.user_server.domain.Member; +import com.asyncgate.user_server.dto.request.ValidateAuthenticationCodeRequest; +import com.asyncgate.user_server.entity.redis.AuthenticationCodeEntity; +import com.asyncgate.user_server.entity.redis.TemporaryMemberEntity; +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.exception.UserServerException; +import com.asyncgate.user_server.repository.MemberRepository; +import com.asyncgate.user_server.repository.redis.AuthenticationCodeRepository; +import com.asyncgate.user_server.repository.redis.TemporaryMemberRepository; +import com.asyncgate.user_server.usecase.ValidateAuthenticationCodeUseCase; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ValidateAuthenticationCodeService implements ValidateAuthenticationCodeUseCase { + private final MemberRepository memberRepository; + private final AuthenticationCodeRepository authenticationCodeRepository; + private final TemporaryMemberRepository temporaryMemberRepository; + + @Value("${cloud.aws.s3.profile.default.url}") + private String defaultProfileImageUrl; + + @Override + @Transactional + public void execute(final ValidateAuthenticationCodeRequest request) { + + AuthenticationCodeEntity storedAuthCode = authenticationCodeRepository.findById(request.email()); + + if (!storedAuthCode.getCode().equals(request.authenticationCode())) { + throw new UserServerException(FailType.INVALID_EMAIL_AUTH_CODE); + } + + // 임시 회원 정보 get + TemporaryMemberEntity tempMember = temporaryMemberRepository.findByEmail(request.email()) + .orElseThrow(() -> new UserServerException(FailType.TEMPORARY_MEMBER_NOT_FOUND)); + + Member member = Member.create(tempMember.getEmail(), tempMember.getPassword(), tempMember.getName(), + tempMember.getNickname(), tempMember.getDeviceToken(), defaultProfileImageUrl, tempMember.getBirth()); + + memberRepository.save(member); + + authenticationCodeRepository.delete(storedAuthCode); + + temporaryMemberRepository.delete(tempMember); + + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/annotation/UseCase.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/annotation/UseCase.java new file mode 100644 index 00000000..35fc2aac --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/annotation/UseCase.java @@ -0,0 +1,20 @@ +package com.asyncgate.user_server.support.annotation; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface UseCase { + /** + * The value may indicate a suggestion for a logical component name, + * to be turned into a Spring bean in case of an autodetected component. + * @return the suggested component name, if any (or empty String otherwise) + */ + @AliasFor(annotation = Component.class) + String value() default ""; +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/handler/GlobalExceptionHandler.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..a6dc494c --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/handler/GlobalExceptionHandler.java @@ -0,0 +1,27 @@ +package com.asyncgate.user_server.support.handler; + + +import com.asyncgate.user_server.exception.UserServerException; +import com.asyncgate.user_server.support.response.FailResponse; +import jakarta.ws.rs.core.NoContentException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public final class GlobalExceptionHandler { + // UserException을 상속받은 예외를 처리하는 핸들러 + @ExceptionHandler(UserServerException.class) + public FailResponse handleGlobalException(UserServerException exception) { + return FailResponse.of( + exception.getFailType().getErrorCode(), + exception.getFailType().getMessage(), + exception.getFailType().getStatus().value() + ); + } + + @ExceptionHandler(NoContentException.class) + public ResponseEntity handleNoContentException(NoContentException exception) { + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/logging/RequestResponseLoggingAspect.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/logging/RequestResponseLoggingAspect.java new file mode 100644 index 00000000..cc6a8b8a --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/logging/RequestResponseLoggingAspect.java @@ -0,0 +1,72 @@ +package com.asyncgate.user_server.support.logging; + +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.UUID; + +@Aspect +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class RequestResponseLoggingAspect { + + private static final Logger requestLogger = LoggerFactory.getLogger("HttpRequestLog"); + private static final Logger responseLogger = LoggerFactory.getLogger("HttpResponseLog"); + + @Pointcut("execution(* com.asyncgate.user_server.controller..*Controller.*(..))") + public void apiControllerMethods() {} + + @Pointcut("!execution(* com.asyncgate.user_server.controller..*Controller.health(..))") + public void excludeHealthCheck() {} + + @Pointcut("apiControllerMethods() && excludeHealthCheck()") + public void apiControllerMethodsExcludingHealthCheck() {} + + @Before("apiControllerMethodsExcludingHealthCheck()") + public void logRequest(JoinPoint joinPoint) { + setMDC(); + requestLogger.info("Request received for method: {}", joinPoint.getSignature().getName()); + } + + @AfterReturning(pointcut = "apiControllerMethodsExcludingHealthCheck()") + public void logResponse() { + String startTimeStr = MDC.get("startTime"); + long startTime = startTimeStr != null ? Long.parseLong(startTimeStr) : 0L; + double executionTime = (System.nanoTime() - startTime) / 1_000_000_000.0; + MDC.put("responseTime", String.format("%.3f초", executionTime)); + responseLogger.info("Response sent successfully"); + } + + @After("apiControllerMethodsExcludingHealthCheck()") + public void clearMDC() { + MDC.clear(); + } + + private void setMDC() { + HttpServletRequest request = getCurrentHttpRequest(); + if (request != null) { + MDC.put("method", request.getMethod()); + MDC.put("requestUri", request.getRequestURI()); + MDC.put("sourceIp", request.getHeader("X-Real-IP") != null ? request.getHeader("X-Real-IP") : request.getRemoteAddr()); + MDC.put("userAgent", request.getHeader("User-Agent")); + MDC.put("xForwardedFor", request.getHeader("X-Forwarded-For")); + MDC.put("xForwardedProto", request.getHeader("X-Forwarded-Proto")); + MDC.put("requestId", UUID.randomUUID().toString()); + MDC.put("startTime", String.valueOf(System.nanoTime())); + } + } + + private HttpServletRequest getCurrentHttpRequest() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes != null ? attributes.getRequest() : null; + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/response/FailResponse.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/response/FailResponse.java index 05c7b091..12b30efc 100644 --- a/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/response/FailResponse.java +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/response/FailResponse.java @@ -12,8 +12,13 @@ public class FailResponse { @JsonProperty("httpStatus") private final int httpStatus; + @JsonProperty("errorCode") private final String errorCode; + + @JsonProperty("message") private final String message; + + @JsonProperty("time") private final LocalDateTime time; private FailResponse(final String errorCode, final String message, final int httpStatus) { diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/response/SuccessResponse.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/response/SuccessResponse.java index db923158..48b399c2 100644 --- a/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/response/SuccessResponse.java +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/response/SuccessResponse.java @@ -13,10 +13,14 @@ public class SuccessResponse { @JsonProperty("httpStatus") private final int httpStatus; + @JsonProperty("message") private final String message; + + @JsonProperty("time") private final LocalDateTime time; @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonProperty("result") private final T result; private SuccessResponse(final int httpStatus, final String message, final T result) { diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/DomainUtil.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/DomainUtil.java new file mode 100644 index 00000000..1faa5d0a --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/DomainUtil.java @@ -0,0 +1,78 @@ +package com.asyncgate.user_server.support.utility; + +import com.asyncgate.user_server.domain.Member; +import com.asyncgate.user_server.domain.AuthenticationCode; +import com.asyncgate.user_server.domain.TemporaryMember; +import com.asyncgate.user_server.entity.MemberEntity; +import com.asyncgate.user_server.entity.redis.AuthenticationCodeEntity; +import com.asyncgate.user_server.entity.redis.TemporaryMemberEntity; + +public class DomainUtil { + public static class MemberMapper { + public static MemberEntity toEntity(final Member member) { + return MemberEntity.builder() + .id(member.getId()) + .email(member.getEmail()) + .password(member.getPassword()) + .name(member.getName()) + .nickname(member.getNickname()) + .deviceToken(member.getDeviceToken()) + .profileImgUrl(member.getProfileImgUrl()) + .birth(member.getBirth()) + .build(); + } + + public static Member toDomain(final MemberEntity entity) { + return Member.builder() + .id(entity.getId()) + .email(entity.getEmail()) + .password(entity.getPassword()) + .name(entity.getName()) + .nickname(entity.getNickname()) + .deviceToken(entity.getDeviceToken()) + .profileImgUrl(entity.getProfileImgUrl()) + .birth(entity.getBirth()) + .build(); + } + } + + public static class TemporaryMemberMapper { + public static TemporaryMemberEntity toEntity(final TemporaryMember member) { + return TemporaryMemberEntity.builder() + .email(member.getEmail()) + .password(member.getPassword()) + .name(member.getName()) + .nickname(member.getNickname()) + .deviceToken(member.getDeviceToken()) + .birth(member.getBirth()) + .build(); + } + + public static TemporaryMember toDomain(final TemporaryMemberEntity entity) { + return TemporaryMember.builder() + .email(entity.getEmail()) + .password(entity.getPassword()) + .name(entity.getName()) + .nickname(entity.getNickname()) + .deviceToken(entity.getDeviceToken()) + .birth(entity.getBirth()) + .build(); + } + } + + public static class AuthenticationCodeMapper { + public static AuthenticationCodeEntity toEntity(final AuthenticationCode code) { + return AuthenticationCodeEntity.builder() + .id(code.getId()) + .code(code.getCode()) + .build(); + } + + public static AuthenticationCode toDomain(final AuthenticationCodeEntity entity) { + return AuthenticationCode.builder() + .id(entity.getId()) + .code(entity.getCode()) + .build(); + } + } +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/EmailUtil.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/EmailUtil.java new file mode 100644 index 00000000..12c1a690 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/EmailUtil.java @@ -0,0 +1,50 @@ +package com.asyncgate.user_server.support.utility; + +import com.asyncgate.user_server.exception.FailType; +import com.asyncgate.user_server.exception.UserServerException; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EmailUtil { + + private static final String AUTHENTICATION_CODE_TEMPLATE = """ +
+

이메일 인증 코드

+

안녕하세요! 아래의 인증 코드를 입력하여 이메일 인증을 완료하세요.

+
${AuthenticationCode}
+

이 코드는 5분 후 만료됩니다.

+

도움이 필요하시면 지원팀에 문의하세요.

+
© 2025 Your Company. 모든 권리 보유.
+
+ """; + + private final JavaMailSender javaMailSender; + + public void sendAuthenticationCode(final String receiverAddress, final String authenticationCode) { + try { + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + mimeMessage.setSubject("Verify Your Email for Discord"); + + // HTML 기반 이메일 생성 + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, true); + mimeMessageHelper.setFrom("Discord"); + mimeMessageHelper.setTo(receiverAddress); + mimeMessageHelper.setText(AUTHENTICATION_CODE_TEMPLATE.replace("${AuthenticationCode}", authenticationCode), true); + + // 이메일 전송 + javaMailSender.send(mimeMessage); + + } catch (MessagingException e) { + throw new UserServerException(FailType._SEND_EMAIL_ERROR); + } catch (Exception e) { + throw new UserServerException(FailType._UNKNOWN_ERROR); + } + } + +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/PasswordUtil.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/PasswordUtil.java new file mode 100644 index 00000000..40480868 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/PasswordUtil.java @@ -0,0 +1,24 @@ +package com.asyncgate.user_server.support.utility; + +/** + * Utility class for password-related operations + */ +public class PasswordUtil { + + /** + * Generate a random authentication code with the given length + * + * @param length The length of the authentication code + * @return The generated authentication code + */ + public static String generateAuthCode(final Integer length) { + StringBuilder authCode = new StringBuilder(); + + // 숫자로만 구성된 인증 코드 생성 + for (int i = 0; i < length; i++) { + authCode.append((int) (Math.random() * 10)); + } + + return authCode.toString(); + } +} \ No newline at end of file diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/utility/S3Util.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/S3Util.java similarity index 97% rename from src/backend/user-server/src/main/java/com/asyncgate/user_server/utility/S3Util.java rename to src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/S3Util.java index b7ea4645..101a030c 100644 --- a/src/backend/user-server/src/main/java/com/asyncgate/user_server/utility/S3Util.java +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/support/utility/S3Util.java @@ -1,4 +1,4 @@ -package com.asyncgate.user_server.utility; +package com.asyncgate.user_server.support.utility; import com.amazonaws.SdkClientException; import com.amazonaws.services.s3.AmazonS3Client; diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/CheckEmailDuplicateUseCase.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/CheckEmailDuplicateUseCase.java new file mode 100644 index 00000000..3a5232e7 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/CheckEmailDuplicateUseCase.java @@ -0,0 +1,14 @@ +package com.asyncgate.user_server.usecase; + +import com.asyncgate.user_server.dto.response.CheckEmailDuplicateResponse; +import com.asyncgate.user_server.support.annotation.UseCase; + +@UseCase +public interface CheckEmailDuplicateUseCase { + /** + * 이메일 중복 검사 + * + * @param email (email) + */ + CheckEmailDuplicateResponse execute(final String email); +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/DeleteUserUseCase.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/DeleteUserUseCase.java new file mode 100644 index 00000000..b9bd5316 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/DeleteUserUseCase.java @@ -0,0 +1,14 @@ +package com.asyncgate.user_server.usecase; + +import com.asyncgate.user_server.support.annotation.UseCase; + +@UseCase +public interface DeleteUserUseCase { + + /** + * 사용자 삭제 + * + * @param userId 사용자 아이디 + */ + void execute(final String userId); +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/LoginMemberUsecase.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/LoginMemberUsecase.java new file mode 100644 index 00000000..55ee2af7 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/LoginMemberUsecase.java @@ -0,0 +1,15 @@ +package com.asyncgate.user_server.usecase; + +import com.asyncgate.user_server.dto.request.LoginMemberRequest; +import com.asyncgate.user_server.dto.response.DefaultJsonWebTokenResponse; +import com.asyncgate.user_server.support.annotation.UseCase; + +@UseCase +public interface LoginMemberUsecase { + /** + * 로그인 + * + * @param request (email, password) + */ + DefaultJsonWebTokenResponse execute(final LoginMemberRequest request); +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/RegisterTemporaryMemberUseCase.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/RegisterTemporaryMemberUseCase.java new file mode 100644 index 00000000..811402ff --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/RegisterTemporaryMemberUseCase.java @@ -0,0 +1,14 @@ +package com.asyncgate.user_server.usecase; + +import com.asyncgate.user_server.dto.request.RegisterTemporaryMemberRequest; +import com.asyncgate.user_server.support.annotation.UseCase; + +@UseCase +public interface RegisterTemporaryMemberUseCase { + /** + * 회원가입 + * + * @param request (email, password, name, nickname, birth) + */ + void execute(final RegisterTemporaryMemberRequest request); +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/UpdateDeviceTokenUseCase.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/UpdateDeviceTokenUseCase.java new file mode 100644 index 00000000..a04dc40f --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/UpdateDeviceTokenUseCase.java @@ -0,0 +1,10 @@ +package com.asyncgate.user_server.usecase; + +import com.asyncgate.user_server.dto.request.UpdateDeviceTokenRequest; +import com.asyncgate.user_server.support.annotation.UseCase; + +@UseCase +public interface UpdateDeviceTokenUseCase { + + void execute(final String userId, final UpdateDeviceTokenRequest request); +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/UpdateUserInfoUseCase.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/UpdateUserInfoUseCase.java new file mode 100644 index 00000000..74918865 --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/UpdateUserInfoUseCase.java @@ -0,0 +1,16 @@ +package com.asyncgate.user_server.usecase; + +import com.asyncgate.user_server.support.annotation.UseCase; +import org.springframework.web.multipart.MultipartFile; + +@UseCase +public interface UpdateUserInfoUseCase { + /** + * 사용자 정보 수정 + * @param userId + * @param name + * @param nickname + * @param profileImage + */ + void execute(final String userId, final String name, final String nickname, final MultipartFile profileImage); +} diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/ValidateAuthenticationCodeUseCase.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/ValidateAuthenticationCodeUseCase.java new file mode 100644 index 00000000..bef33a4f --- /dev/null +++ b/src/backend/user-server/src/main/java/com/asyncgate/user_server/usecase/ValidateAuthenticationCodeUseCase.java @@ -0,0 +1,14 @@ +package com.asyncgate.user_server.usecase; + +import com.asyncgate.user_server.dto.request.ValidateAuthenticationCodeRequest; +import com.asyncgate.user_server.support.annotation.UseCase; + +@UseCase +public interface ValidateAuthenticationCodeUseCase { + /** + * 인증번호 인증 + * + * @param request (email, authenticationCode) + */ + void execute(final ValidateAuthenticationCodeRequest request); +}