diff --git a/be/build.gradle b/be/build.gradle index 0568a67a..74405f5a 100644 --- a/be/build.gradle +++ b/be/build.gradle @@ -49,6 +49,12 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // jwt + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + implementation 'com.sun.xml.bind:jaxb-impl:2.3.3' + implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1' } tasks.named('bootBuildImage') { diff --git a/be/src/main/java/yeonba/be/config/WebConfig.java b/be/src/main/java/yeonba/be/config/WebConfig.java index 630081d9..2d996c0a 100644 --- a/be/src/main/java/yeonba/be/config/WebConfig.java +++ b/be/src/main/java/yeonba/be/config/WebConfig.java @@ -19,7 +19,12 @@ public void addInterceptors(InterceptorRegistry registry) { "/swagger-resources/**", "/v2/api-docs", "/webjars/**", - "/error"); + "/error") + .excludePathPatterns( + "/users/join/**", + "/users/email-inquiry/**", + "/users/pw-inquiry", + "/users/login", + "/users/refresh"); } - } diff --git a/be/src/main/java/yeonba/be/exception/JoinException.java b/be/src/main/java/yeonba/be/exception/JoinException.java index a71374ce..9eddcca2 100644 --- a/be/src/main/java/yeonba/be/exception/JoinException.java +++ b/be/src/main/java/yeonba/be/exception/JoinException.java @@ -4,6 +4,30 @@ public enum JoinException implements BaseException { + PASSWORD_CONFIRMATION_NOT_MATCH( + HttpStatus.BAD_REQUEST, + "비밀번호 확인 값이 비밀번호와 일치하지 않습니다."), + + VOCAL_RANGE_NOT_FOUND( + HttpStatus.BAD_REQUEST, + "존재하지 않는 음역대입니다."), + + ANIMAL_NOT_FOUND( + HttpStatus.BAD_REQUEST, + "존재하지 않는 동물상입니다."), + + AREA_NOT_FOUND( + HttpStatus.BAD_REQUEST, + "존재하지 않는 지역입니다."), + + ALREADY_USED_EMAIL( + HttpStatus.BAD_REQUEST, + "이미 사용 중인 이메일입니다."), + + ALREADY_USED_NICKNAME( + HttpStatus.BAD_REQUEST, + "이미 사용 중인 닉네임입니다."), + ALREADY_USED_PHONE_NUMBER( HttpStatus.BAD_REQUEST, "이미 사용 중인 핸드폰 번호입니다."); diff --git a/be/src/main/java/yeonba/be/exception/UtilException.java b/be/src/main/java/yeonba/be/exception/UtilException.java new file mode 100644 index 00000000..52c16baa --- /dev/null +++ b/be/src/main/java/yeonba/be/exception/UtilException.java @@ -0,0 +1,35 @@ +package yeonba.be.exception; + +import org.springframework.http.HttpStatus; + +public enum UtilException implements BaseException { + + INVALID_JWT( + HttpStatus.BAD_REQUEST, + "유효하지 않은 JWT입니다. 다시 로그인 해주세요."), + + NOT_ALLOWED_IMAGE_FILE_EXTENSION( + HttpStatus.BAD_REQUEST, + "jpg, jpeg, png 확장자 형식의 파일만 허용됩니다."); + + private final HttpStatus httpStatus; + private final String reason; + + UtilException(HttpStatus httpStatus, String reason) { + + this.httpStatus = httpStatus; + this.reason = reason; + } + + @Override + public HttpStatus getHttpStatus() { + + return httpStatus; + } + + @Override + public String getReason() { + + return reason; + } +} diff --git a/be/src/main/java/yeonba/be/login/controller/LoginController.java b/be/src/main/java/yeonba/be/login/controller/LoginController.java index 23629e22..6b87caa5 100644 --- a/be/src/main/java/yeonba/be/login/controller/LoginController.java +++ b/be/src/main/java/yeonba/be/login/controller/LoginController.java @@ -6,6 +6,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -21,6 +22,7 @@ import yeonba.be.login.dto.response.UserLoginResponse; import yeonba.be.login.dto.response.UserRefreshTokenResponse; import yeonba.be.login.service.LoginService; +import yeonba.be.user.service.JoinService; import yeonba.be.util.CustomResponse; @Tag(name = "Login", description = "로그인 관련 API") @@ -29,17 +31,18 @@ public class LoginController { private final LoginService loginService; + private final JoinService joinService; @Operation(summary = "회원가입", description = "회원가입을 할 수 있습니다.") - @PostMapping("/users/join") + @PostMapping(path = "/users/join", consumes = "multipart/form-data") public ResponseEntity> join( - @RequestBody UserJoinRequest request) { + @Valid @ModelAttribute UserJoinRequest request) { - String createdJwt = "created"; + UserJoinResponse response = joinService.join(request); return ResponseEntity .ok() - .body(new CustomResponse<>(new UserJoinResponse(createdJwt))); + .body(new CustomResponse<>(response)); } @Operation(summary = "이메일 찾기 인증 코드 sms 전송", description = "이메일 찾기를 위한 인증번호 sms 전송을 요청합니다.") @@ -82,6 +85,7 @@ public ResponseEntity> passwordInquiry( } @Operation(summary = "로그인", description = "로그인을 할 수 있습니다.") + @ApiResponse(responseCode = "200", description = "로그인 성공") @PostMapping("/users/login") public ResponseEntity> login( @RequestBody UserLoginRequest request) { diff --git a/be/src/main/java/yeonba/be/login/dto/request/UserJoinRequest.java b/be/src/main/java/yeonba/be/login/dto/request/UserJoinRequest.java index 26715b4f..bafd2d58 100644 --- a/be/src/main/java/yeonba/be/login/dto/request/UserJoinRequest.java +++ b/be/src/main/java/yeonba/be/login/dto/request/UserJoinRequest.java @@ -1,51 +1,225 @@ package yeonba.be.login.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; @Getter -@NoArgsConstructor @AllArgsConstructor public class UserJoinRequest { - @Schema(description = "휴대폰 번호", example = "01012345678") - private String phoneNumber; + @Schema( + type = "string", + description = "성별", + example = "남") + @Pattern( + regexp = "^(남|여)$", + message = "성별은 남 또는 여만 가능합니다.") + @NotBlank(message = "성별은 반드시 입력되어야 합니다.") + private String gender; - @Schema(description = "이름", example = "안민재") - private String name; + @Schema( + type = "string", + description = "전화번호", + example = "01011112222") + @Pattern( + regexp = "^010\\d{8}$", + message = "전화번호는 11자리 010으로 시작하며 하이픈(-) 없이 0~9의 숫자로 이뤄져야 합니다.") + @NotBlank(message = "전화번호는 반드시 입력되어야 합니다.") + private String phoneNumber; - @Schema(description = "생일", example = "980315") - private String birth; + @Schema( + type = "string", + description = "비밀번호", + example = "Aa1234!@") + @Pattern( + regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[~#@!]).{8,20}$", + message = """ + 비밀번호는 영어대소문자, 숫자, 특수문자(~#@!)를 + 최소 1자씩 포함하며 8~20자 사이여야 합니다.""") + @NotBlank(message = "비밀번호는 반드시 입력되어야 합니다.") + private String password; - @Schema(description = "성별", example = "남자") - private String gender; + @Schema( + type = "string", + description = "비밀번호 확인값", + example = "Aa1234!@") + @NotBlank(message = "비밀번호 확인값은 반드시 입력되어야 합니다.") + private String passwordConfirmation; - @Schema(description = "이메일", example = "anminjae@gmail.com") + @Schema( + type = "string", + description = "이메일", + example = "mj3242@naver.com") + @Pattern( + regexp = "[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$", + message = "유효하지 않은 이메일 형식입니다.") + @NotBlank(message = "이메일은 반드시 입력되어야 합니다.") private String email; - @Schema(description = "닉네임", example = "calm_min") + @Schema( + type = "string", + description = "생년월일", + example = "1998-04-08") + @NotNull(message = "생년월일은 반드시 입력되어야 합니다.") + private LocalDate birth; + + @Schema( + type = "string", + description = "이름", + example = "안민재") + @NotBlank(message = " 이름은 반드시 입력되어야 합니다.") + private String name; + + @Schema( + type = "string", + description = "닉네임", + example = "존잘남") + @Pattern( + regexp = "^[a-zA-Z0-9가-힣]{1,8}$", + message = "닉네임은 공백 없이 영어 대소문자,한글,숫자로 구성되어야 하며 최대 8자까지 가능합니다.") + @NotBlank(message = "닉네임은 반드시 입력되어야 합니다.") private String nickname; - @Schema(description = "키", example = "160") + @Schema( + type = "number", + description = "키", + example = "180") + @Positive(message = "키는 양의 정수여야 합니다.") private int height; - @Schema(description = "활동 지역", example = "서울시 성북구") - private String activityArea; + @Schema( + type = "string", + description = "체형", + example = "마른체형") + @NotBlank(message = "체형은 반드시 입력되어야 합니다.") + private String bodyType; - @Schema(description = "선호하는 동물상 ID", example = "1") - private int preferAnimalId; + @Schema( + type = "string", + description = "직업", + example = "학생") + @NotBlank(message = "직업은 반드시 입력되어야 합니다.") + private String job; - @Schema(description = "닮은 동물상 ID", example = "1") - private int lookAlikeAnimalId; + @Schema( + type = "string", + description = "활동 지역", + example = "서울") + @NotBlank(message = "활동 지역은 반드시 입력되어야 합니다.") + private String activityArea; - @Schema(description = "프로필 이미지", example = "https://avatars.githubusercontent.com/u/156646513?s=200&v=4") - private String[] images; + @Schema( + type = "string", + description = "MBTI", + example = "ESTJ") + @Pattern( + regexp = "^[EI][SN][TF][JP]$", + message = "유효하지 않은 MBTI 형식입니다.") + @NotBlank(message = "MBTI는 반드시 입력되어야 합니다.") + private String mbti; - @Schema(description = "목소리", example = "중음") + @Schema( + type = "string", + description = "음역대", + example = "저음") + @NotBlank(message = "음역대는 반드시 입력되어야 합니다.") private String vocalRange; - @Schema(description = "사진 싱그로율", example = "60") + @Schema( + type = "array", + description = "프로필 사진 파일들") + @Size(min = 2, max = 2) + private List profilePhotos; + + @Schema( + type = "number", + description = "사진 싱크로율", + example = "80") + @Min( + value = 80, + message = "사진 싱크로율이 80퍼 이상이어야 가입할 수 있습니다.") private int photoSyncRate; + + @Schema( + type = "string", + description = "닮은 동물상", + example = "강아지상") + @NotBlank(message = "닮은 동물상은 반드시 입력되어야 합니다.") + private String lookAlikeAnimal; + + @Schema( + type = "string", + description = "선호하는 동물상", + example = "강아지상") + @NotBlank(message = "선호하는 동물상은 반드시 입력되어야 합니다.") + private String preferredAnimal; + + @Schema( + type = "string", + description = "선호하는 지역", + example = "서울") + @NotBlank(message = "선호하는 지역은 반드시 입력되어야 합니다.") + private String preferredArea; + + @Schema( + type = "string", + description = "선호하는 음역대", + example = "저음") + @NotBlank(message = "선호하는 음역대는 반드시 입력되어야 합니다.") + private String preferredVocalRange; + + @Schema( + type = "number", + description = "선호하는 나이 하한", + example = "22") + @Positive(message = "선호하는 나이 하한은 양수여야 합니다.") + private int preferredAgeLowerBound; + + @Schema( + type = "number", + description = "선호하는 나이 상한", + example = "30") + @Positive(message = "선호하는 나이 상한은 양수여야 합니다.") + private int preferredAgeUpperBound; + + @Schema( + type = "number", + description = "선호하는 키 하한", + example = "177") + @Positive(message = "선호하는 키 하한은 양수여야 합니다.") + private int preferredHeightLowerBound; + + @Schema( + type = "number", + description = "선호하는 키 상한", + example = "185") + @Positive(message = "선호하는 키 상한은 양수여야 합니다.") + private int preferredHeightUpperBound; + + @Schema( + type = "string", + description = "선호하는 체형", + example = "마른체형") + @NotBlank(message = "선호하는 체형은 반드시 입력되어야 합니다.") + private String preferredBodyType; + + @Schema( + type = "string", + description = "선호하는 MBTI", + example = "ISTJ") + @Pattern( + regexp = "^[EI][SN][TF][JP]$", + message = "유효하지 않은 MBTI 형식입니다.") + @NotBlank(message = "선호하는 MBTI는 반드시 입력되어야 합니다.") + private String preferredMbti; } diff --git a/be/src/main/java/yeonba/be/login/dto/response/UserJoinResponse.java b/be/src/main/java/yeonba/be/login/dto/response/UserJoinResponse.java index 892e2d19..aa608c54 100644 --- a/be/src/main/java/yeonba/be/login/dto/response/UserJoinResponse.java +++ b/be/src/main/java/yeonba/be/login/dto/response/UserJoinResponse.java @@ -8,6 +8,15 @@ @AllArgsConstructor public class UserJoinResponse { - @Schema(description = "JWT", example = "header.payload.signature") - private String jwt; + @Schema( + type = "string", + description = "access token", + example = "header.payload,signature") + private String accessToken; + + @Schema( + type = "string", + description = "refresh token", + example = "header.payload.signature") + private String refreshToken; } diff --git a/be/src/main/java/yeonba/be/login/service/LoginService.java b/be/src/main/java/yeonba/be/login/service/LoginService.java index 2c9973dc..d2ea17cb 100644 --- a/be/src/main/java/yeonba/be/login/service/LoginService.java +++ b/be/src/main/java/yeonba/be/login/service/LoginService.java @@ -20,6 +20,7 @@ import yeonba.be.user.entity.User; import yeonba.be.user.repository.UserQuery; import yeonba.be.util.EmailService; +import yeonba.be.util.PasswordEncryptor; import yeonba.be.util.SmsService; import yeonba.be.util.TemporaryPasswordGenerator; import yeonba.be.util.VerificationCodeGenerator; @@ -41,16 +42,15 @@ public class LoginService { private final EmailService emailService; private final SmsService smsService; - /* - 임시 비밀번호는 다음 과정을 거친다. + private final PasswordEncryptor passwordEncryptor; + + /* + 임시 비밀번호는 다음 과정을 거친다. 1. 요청 이메일 기반 사용자 조회 2. 임시 비밀번호 생성 3. 사용자 비밀번호, 임시 비밀번호로 변경 4. 임시 비밀번호 발급 메일 전송 - */ - - // TODO : 비밀번호 암호화 로직 추가 - + */ @Transactional public void sendTemporaryPasswordMail(UserPasswordInquiryRequest request) { @@ -59,7 +59,7 @@ public void sendTemporaryPasswordMail(UserPasswordInquiryRequest request) { String temporaryPassword = TemporaryPasswordGenerator.generatePassword(); - String encryptedPassword = temporaryPassword; + String encryptedPassword = passwordEncryptor.encrypt(temporaryPassword, user.getSalt()); user.changePassword(encryptedPassword); String text = String.format(TEMPORARY_PASSWORD_EMAIL_TEXT, temporaryPassword); @@ -69,9 +69,10 @@ public void sendTemporaryPasswordMail(UserPasswordInquiryRequest request) { @Transactional public void sendVerificationCodeMessage(UserVerificationCodeRequest request) { - // 전화 번호로 사용자 조회 + // 해당 번호를 가진 사용자가 존재하는 지 확인 String phoneNumber = request.getPhoneNumber(); - if (!userQuery.existByPhoneNumber(phoneNumber)) { + if (!userQuery.validateUsedPhoneNumber(phoneNumber)) { + throw new GeneralException(UserException.USER_NOT_FOUND); } @@ -109,7 +110,7 @@ public UserEmailInquiryResponse findEmail(UserEmailInquiryRequest request) { public void sendJoinVerificationCodeMessage(UserVerificationCodeRequest request) { // 이미 사용 중인 번호인 지 검증 - if (userQuery.existByPhoneNumber(request.getPhoneNumber())) { + if (userQuery.validateUsedPhoneNumber(request.getPhoneNumber())) { throw new GeneralException(JoinException.ALREADY_USED_PHONE_NUMBER); } diff --git a/be/src/main/java/yeonba/be/mypage/service/MyPageService.java b/be/src/main/java/yeonba/be/mypage/service/MyPageService.java index 3b379d60..4eea8e14 100644 --- a/be/src/main/java/yeonba/be/mypage/service/MyPageService.java +++ b/be/src/main/java/yeonba/be/mypage/service/MyPageService.java @@ -19,12 +19,12 @@ import yeonba.be.mypage.dto.response.BlockedUsersResponse; import yeonba.be.mypage.dto.response.UserProfileDetailResponse; import yeonba.be.mypage.dto.response.UserSimpleProfileResponse; -import yeonba.be.mypage.util.PasswordEncryptor; import yeonba.be.user.entity.Block; import yeonba.be.user.entity.User; import yeonba.be.user.repository.BlockCommand; import yeonba.be.user.repository.BlockQuery; import yeonba.be.user.repository.UserQuery; +import yeonba.be.util.PasswordEncryptor; @Service @RequiredArgsConstructor diff --git a/be/src/main/java/yeonba/be/user/dto/response/UserProfileResponse.java b/be/src/main/java/yeonba/be/user/dto/response/UserProfileResponse.java index 718b8ba3..3aea5ad0 100644 --- a/be/src/main/java/yeonba/be/user/dto/response/UserProfileResponse.java +++ b/be/src/main/java/yeonba/be/user/dto/response/UserProfileResponse.java @@ -9,68 +9,68 @@ @AllArgsConstructor public class UserProfileResponse { - @Schema( - type = "array", - description = "프로필 사진 URL들") - private List profilePhotosUrls; + @Schema( + type = "array", + description = "프로필 사진 URL들") + private List profilePhotosUrls; - @Schema( - type = "string", - description = "성별", - example = "남") - private String gender; + @Schema( + type = "string", + description = "성별", + example = "남") + private String gender; - @Schema( - type = "string", - description = "닉네임", - example = "존잘남") - private String nickname; + @Schema( + type = "string", + description = "닉네임", + example = "존잘남") + private String nickname; - @Schema( - type = "number", - description = "사용자가 가진 총 화살 수", - example = "10") - private int arrows; + @Schema( + type = "number", + description = "사용자가 가진 총 화살 수", + example = "10") + private int arrows; - @Schema( - type = "number", - description = "나이", - example = "23") - private int age; + @Schema( + type = "number", + description = "나이", + example = "23") + private int age; - @Schema( - type = "number", - description = "키", - example = "177") - private int height; + @Schema( + type = "number", + description = "키", + example = "177") + private int height; - @Schema( - type = "string", - description = "활동 지역", - example = "서울") - private String activityArea; + @Schema( + type = "string", + description = "활동 지역", + example = "서울") + private String activityArea; - @Schema( - type = "number", - description = "사진 싱크로율", - example = "80") - private int photoSyncRate; + @Schema( + type = "number", + description = "사진 싱크로율", + example = "80") + private int photoSyncRate; - @Schema( - type = "string", - description = "음역대", - example = "저음") - private String vocalRange; + @Schema( + type = "string", + description = "음역대", + example = "저음") + private String vocalRange; - @Schema( - type = "string", - description = "닮은 동물상", - example = "여우상") - private String lookAlikeAnimalName; + @Schema( + type = "string", + description = "닮은 동물상", + example = "여우상") + private String lookAlikeAnimalName; - @Schema( - type = "boolean", - description = "이전 화살 전송 여부", - example = "false") - private boolean isAlreadySentArrow; + @Schema( + type = "boolean", + description = "이전 화살 전송 여부", + example = "false") + private boolean isAlreadySentArrow; } diff --git a/be/src/main/java/yeonba/be/user/entity/ProfilePhoto.java b/be/src/main/java/yeonba/be/user/entity/ProfilePhoto.java index 55357ca4..2353d5c0 100644 --- a/be/src/main/java/yeonba/be/user/entity/ProfilePhoto.java +++ b/be/src/main/java/yeonba/be/user/entity/ProfilePhoto.java @@ -1,6 +1,7 @@ package yeonba.be.user.entity; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -8,15 +9,16 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalDateTime; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Table(name = "profile_photos") @Getter @Entity +@EntityListeners(value = AuditingEntityListener.class) @NoArgsConstructor -@AllArgsConstructor public class ProfilePhoto { @Id @@ -28,11 +30,13 @@ public class ProfilePhoto { private User user; private String photoUrl; + + @CreatedDate private LocalDateTime createdAt; - public ProfilePhoto(User user, String photoUrl, LocalDateTime createdAt) { + public ProfilePhoto(User user, String photoUrl) { + this.user = user; this.photoUrl = photoUrl; - this.createdAt = createdAt; } } diff --git a/be/src/main/java/yeonba/be/user/entity/User.java b/be/src/main/java/yeonba/be/user/entity/User.java index f45baa39..de4f5744 100644 --- a/be/src/main/java/yeonba/be/user/entity/User.java +++ b/be/src/main/java/yeonba/be/user/entity/User.java @@ -71,6 +71,7 @@ public class User { @Column(nullable = false) private String mbti; + private String refreshToken; @ManyToOne @JoinColumn(name = "vocal_range_id") @@ -97,8 +98,8 @@ public class User { private LocalDateTime deletedAt; - @Column(name = "is_deleted") - private boolean deleted; + @Column(name = "is_deleted") + private boolean deleted; @OneToMany(mappedBy = "blockedUser", fetch = FetchType.LAZY) private List blocks; @@ -121,8 +122,7 @@ public User( String mbti, VocalRange vocalRange, Animal animal, - Area area, - List profilePhotos) { + Area area) { this.gender = gender; this.name = name; @@ -143,7 +143,6 @@ public User( this.vocalRange = vocalRange; this.animal = animal; this.area = area; - this.profilePhotos = profilePhotos; } public void validateSameUser(User user) { @@ -170,6 +169,16 @@ public void delete(LocalDateTime willDeleteTime) { this.deletedAt = willDeleteTime; } + /** + * 삭제된 사용자인지 검증 + */ + public void validateDeletedUser(LocalDateTime now) { + + if (this.deletedAt.isAfter(now)) { + throw new IllegalArgumentException("삭제된 사용자입니다."); + } + } + public void validateDailyCheck(LocalDate dailyCheckDay) { if (this.lastAccessedAt.isAfter(dailyCheckDay.atStartOfDay())) { @@ -230,6 +239,16 @@ public void hideUserInfo() { this.height = 0; this.email = "deleted"; this.phoneNumber = "deleted"; - this.deleted = true; + this.deleted = true; + } + + public void updateProfilePhotos(List profilePhotos) { + + this.profilePhotos = profilePhotos; + } + + public void updateRefreshToken(String refreshToken) { + + this.refreshToken = refreshToken; } } diff --git a/be/src/main/java/yeonba/be/user/entity/UserPreference.java b/be/src/main/java/yeonba/be/user/entity/UserPreference.java new file mode 100644 index 00000000..eb3f3e18 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/entity/UserPreference.java @@ -0,0 +1,83 @@ +package yeonba.be.user.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "users_preferences") +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserPreference { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private int ageLowerBound; + + @Column(nullable = false) + private int ageUpperBound; + + @Column(nullable = false) + private int heightLowerBound; + + @Column(nullable = false) + private int heightUpperBound; + + @Column(nullable = false) + private String mbti; + + @Column(nullable = false) + private String bodyType; + + @OneToOne + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @JoinColumn(name = "vocal_range_id") + private VocalRange vocalRange; + + @ManyToOne + @JoinColumn(name = "area_id") + private Area area; + + @ManyToOne + @JoinColumn(name = "animal_id") + private Animal animal; + + public UserPreference( + int ageLowerBound, + int ageUpperBound, + int heightLowerBound, + int heightUpperBound, + String mbti, + String bodyType, + User user, + VocalRange vocalRange, + Area area, + Animal animal) { + + this.ageLowerBound = ageLowerBound; + this.ageUpperBound = ageUpperBound; + this.heightLowerBound = heightLowerBound; + this.heightUpperBound = heightUpperBound; + this.mbti = mbti; + this.bodyType = bodyType; + this.user = user; + this.vocalRange = vocalRange; + this.area = area; + this.animal = animal; + } +} diff --git a/be/src/main/java/yeonba/be/user/enums/Gender.java b/be/src/main/java/yeonba/be/user/enums/Gender.java new file mode 100644 index 00000000..0668205c --- /dev/null +++ b/be/src/main/java/yeonba/be/user/enums/Gender.java @@ -0,0 +1,27 @@ +package yeonba.be.user.enums; + +import org.apache.commons.lang3.StringUtils; + +public enum Gender { + + MALE("남", true), + FEMALE("여", false); + + public final String genderString; + public final boolean genderBoolean; + + Gender(String genderString, boolean genderBoolean) { + this.genderString = genderString; + this.genderBoolean = genderBoolean; + } + + public static Gender from(String genderString) { + + if (StringUtils.equals(genderString, MALE.genderString)) { + + return MALE; + } + + return FEMALE; + } +} diff --git a/be/src/main/java/yeonba/be/user/repository/UserCommand.java b/be/src/main/java/yeonba/be/user/repository/UserCommand.java new file mode 100644 index 00000000..d2c077bc --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/UserCommand.java @@ -0,0 +1,17 @@ +package yeonba.be.user.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import yeonba.be.user.entity.User; + +@Component +@RequiredArgsConstructor +public class UserCommand { + + private final UserRepository userRepository; + + public User save(User user) { + + return userRepository.save(user); + } +} diff --git a/be/src/main/java/yeonba/be/user/repository/UserQuery.java b/be/src/main/java/yeonba/be/user/repository/UserQuery.java index 21d3fc81..9dd1ff00 100644 --- a/be/src/main/java/yeonba/be/user/repository/UserQuery.java +++ b/be/src/main/java/yeonba/be/user/repository/UserQuery.java @@ -25,12 +25,7 @@ public User findByEmail(String email) { return userRepository.findByEmail(email) .orElseThrow(() -> new GeneralException(UserException.USER_NOT_FOUND)); } - - public boolean existByPhoneNumber(String phoneNumber) { - - return userRepository.existsByPhoneNumber(phoneNumber); - } - + public User findByPhoneNumber(String phoneNumber) { return userRepository.findByPhoneNumber(phoneNumber) @@ -43,4 +38,19 @@ public List findWillDeleteUsers() { return userRepository.findAllByDeletedAtIsBeforeAndDeletedIsFalse(now); } + + public boolean validateUsedEmail(String email) { + + return userRepository.existsByEmail(email); + } + + public boolean validateUsedNickname(String nickname) { + + return userRepository.existsByNickname(nickname); + } + + public boolean validateUsedPhoneNumber(String phoneNumber) { + + return userRepository.existsByPhoneNumber(phoneNumber); + } } diff --git a/be/src/main/java/yeonba/be/user/repository/UserRepository.java b/be/src/main/java/yeonba/be/user/repository/UserRepository.java index 1947fe83..a4b9e370 100644 --- a/be/src/main/java/yeonba/be/user/repository/UserRepository.java +++ b/be/src/main/java/yeonba/be/user/repository/UserRepository.java @@ -19,4 +19,8 @@ public interface UserRepository extends JpaRepository { Optional findByPhoneNumber(String phoneNumber); List findAllByDeletedAtIsBeforeAndDeletedIsFalse(LocalDateTime now); + + boolean existsByNickname(String nickname); + + boolean existsByEmail(String email); } diff --git a/be/src/main/java/yeonba/be/user/repository/animal/AnimalQuery.java b/be/src/main/java/yeonba/be/user/repository/animal/AnimalQuery.java new file mode 100644 index 00000000..1cfbd8c3 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/animal/AnimalQuery.java @@ -0,0 +1,20 @@ +package yeonba.be.user.repository.animal; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import yeonba.be.exception.GeneralException; +import yeonba.be.exception.JoinException; +import yeonba.be.user.entity.Animal; + +@Component +@RequiredArgsConstructor +public class AnimalQuery { + + private final AnimalRepository animalRepository; + + public Animal findByName(String name) { + + return animalRepository.findByName(name) + .orElseThrow(() -> new GeneralException(JoinException.ANIMAL_NOT_FOUND)); + } +} diff --git a/be/src/main/java/yeonba/be/user/repository/animal/AnimalRepository.java b/be/src/main/java/yeonba/be/user/repository/animal/AnimalRepository.java new file mode 100644 index 00000000..2a40e91e --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/animal/AnimalRepository.java @@ -0,0 +1,12 @@ +package yeonba.be.user.repository.animal; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import yeonba.be.user.entity.Animal; + +@Repository +public interface AnimalRepository extends JpaRepository { + + Optional findByName(String name); +} diff --git a/be/src/main/java/yeonba/be/user/repository/area/AreaQuery.java b/be/src/main/java/yeonba/be/user/repository/area/AreaQuery.java new file mode 100644 index 00000000..d803a69c --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/area/AreaQuery.java @@ -0,0 +1,20 @@ +package yeonba.be.user.repository.area; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import yeonba.be.exception.GeneralException; +import yeonba.be.exception.JoinException; +import yeonba.be.user.entity.Area; + +@Component +@RequiredArgsConstructor +public class AreaQuery { + + private final AreaRepository areaRepository; + + public Area findByName(String name) { + + return areaRepository.findByName(name) + .orElseThrow(() -> new GeneralException(JoinException.AREA_NOT_FOUND)); + } +} diff --git a/be/src/main/java/yeonba/be/user/repository/area/AreaRepository.java b/be/src/main/java/yeonba/be/user/repository/area/AreaRepository.java new file mode 100644 index 00000000..de0e5074 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/area/AreaRepository.java @@ -0,0 +1,12 @@ +package yeonba.be.user.repository.area; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import yeonba.be.user.entity.Area; + +@Repository +public interface AreaRepository extends JpaRepository { + + Optional findByName(String name); +} diff --git a/be/src/main/java/yeonba/be/user/repository/profilephoto/ProfilePhotoCommand.java b/be/src/main/java/yeonba/be/user/repository/profilephoto/ProfilePhotoCommand.java new file mode 100644 index 00000000..f59b02e4 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/profilephoto/ProfilePhotoCommand.java @@ -0,0 +1,18 @@ +package yeonba.be.user.repository.profilephoto; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import yeonba.be.user.entity.ProfilePhoto; + +@Component +@RequiredArgsConstructor +public class ProfilePhotoCommand { + + private final ProfilePhotoRepository profilePhotoRepository; + + public List save(List profilePhotos) { + + return profilePhotoRepository.saveAll(profilePhotos); + } +} diff --git a/be/src/main/java/yeonba/be/user/repository/profilephoto/ProfilePhotoRepository.java b/be/src/main/java/yeonba/be/user/repository/profilephoto/ProfilePhotoRepository.java new file mode 100644 index 00000000..d81e2409 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/profilephoto/ProfilePhotoRepository.java @@ -0,0 +1,10 @@ +package yeonba.be.user.repository.profilephoto; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import yeonba.be.user.entity.ProfilePhoto; + +@Repository +public interface ProfilePhotoRepository extends JpaRepository { + +} diff --git a/be/src/main/java/yeonba/be/user/repository/userpreference/UserPreferenceCommand.java b/be/src/main/java/yeonba/be/user/repository/userpreference/UserPreferenceCommand.java new file mode 100644 index 00000000..809b5d9c --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/userpreference/UserPreferenceCommand.java @@ -0,0 +1,17 @@ +package yeonba.be.user.repository.userpreference; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import yeonba.be.user.entity.UserPreference; + +@Component +@RequiredArgsConstructor +public class UserPreferenceCommand { + + private final UserPreferenceRepository userPreferenceRepository; + + public UserPreference save(UserPreference userPreference) { + + return userPreferenceRepository.save(userPreference); + } +} diff --git a/be/src/main/java/yeonba/be/user/repository/userpreference/UserPreferenceRepository.java b/be/src/main/java/yeonba/be/user/repository/userpreference/UserPreferenceRepository.java new file mode 100644 index 00000000..21fc69b4 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/userpreference/UserPreferenceRepository.java @@ -0,0 +1,10 @@ +package yeonba.be.user.repository.userpreference; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import yeonba.be.user.entity.UserPreference; + +@Repository +public interface UserPreferenceRepository extends JpaRepository { + +} diff --git a/be/src/main/java/yeonba/be/user/repository/vocalrange/VocalRangeQuery.java b/be/src/main/java/yeonba/be/user/repository/vocalrange/VocalRangeQuery.java new file mode 100644 index 00000000..24a11a90 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/vocalrange/VocalRangeQuery.java @@ -0,0 +1,20 @@ +package yeonba.be.user.repository.vocalrange; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import yeonba.be.exception.GeneralException; +import yeonba.be.exception.JoinException; +import yeonba.be.user.entity.VocalRange; + +@Component +@RequiredArgsConstructor +public class VocalRangeQuery { + + private final VocalRangeRepository vocalRangeRepository; + + public VocalRange findBy(String classification) { + + return vocalRangeRepository.findByClassification(classification) + .orElseThrow(() -> new GeneralException(JoinException.VOCAL_RANGE_NOT_FOUND)); + } +} diff --git a/be/src/main/java/yeonba/be/user/repository/vocalrange/VocalRangeRepository.java b/be/src/main/java/yeonba/be/user/repository/vocalrange/VocalRangeRepository.java new file mode 100644 index 00000000..504b94d3 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/vocalrange/VocalRangeRepository.java @@ -0,0 +1,12 @@ +package yeonba.be.user.repository.vocalrange; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import yeonba.be.user.entity.VocalRange; + +@Repository +public interface VocalRangeRepository extends JpaRepository { + + Optional findByClassification(String classification); +} diff --git a/be/src/main/java/yeonba/be/user/service/JoinService.java b/be/src/main/java/yeonba/be/user/service/JoinService.java new file mode 100644 index 00000000..88cb7e51 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/service/JoinService.java @@ -0,0 +1,46 @@ +package yeonba.be.user.service; + +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import yeonba.be.login.dto.request.UserJoinRequest; +import yeonba.be.login.dto.response.UserJoinResponse; +import yeonba.be.user.entity.User; +import yeonba.be.util.JwtUtil; + +@Service +@RequiredArgsConstructor +public class JoinService { + + private final UserService userService; + + private final JwtUtil jwtUtil; + + /* + 회원 가입 비즈니스 로직 과정 + 1. 사용자 엔티티 저장 + 2. 프로필 사진 엔티티 저장 + 3. 사용자 선호조건 저장 + 4. access token 및 refresh token 발급 + 5. 사용자 refresh token 업데이트 + */ + @Transactional + public UserJoinResponse join(UserJoinRequest request) { + + // 사용자, 프로필 사진, 선호 조건 엔티티 생성 및 저장 + User user = userService.saveUser(request); + userService.saveProfilePhotos(user, request); + userService.saveUserPreference(user, request); + + // access token, refresh token 발급 + Date issuedAt = new Date(); + String accessToken = jwtUtil.generateAccessToken(user, issuedAt); + String refreshToken = jwtUtil.generateRefreshToken(user, issuedAt); + + // 사용자 refresh token 업데이트 + user.updateRefreshToken(refreshToken); + + return new UserJoinResponse(accessToken, refreshToken); + } +} diff --git a/be/src/main/java/yeonba/be/user/service/UserService.java b/be/src/main/java/yeonba/be/user/service/UserService.java index b14aa08b..9dd4f912 100644 --- a/be/src/main/java/yeonba/be/user/service/UserService.java +++ b/be/src/main/java/yeonba/be/user/service/UserService.java @@ -1,19 +1,55 @@ package yeonba.be.user.service; +import java.time.LocalDate; +import java.time.Period; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import yeonba.be.arrow.repository.ArrowQuery; +import yeonba.be.exception.GeneralException; +import yeonba.be.exception.JoinException; +import yeonba.be.login.dto.request.UserJoinRequest; import yeonba.be.user.dto.response.UserProfileResponse; +import yeonba.be.user.entity.Animal; +import yeonba.be.user.entity.Area; +import yeonba.be.user.entity.ProfilePhoto; import yeonba.be.user.entity.User; +import yeonba.be.user.entity.UserPreference; +import yeonba.be.user.entity.VocalRange; +import yeonba.be.user.enums.Gender; +import yeonba.be.user.repository.UserCommand; import yeonba.be.user.repository.UserQuery; +import yeonba.be.user.repository.animal.AnimalQuery; +import yeonba.be.user.repository.area.AreaQuery; +import yeonba.be.user.repository.profilephoto.ProfilePhotoCommand; +import yeonba.be.user.repository.userpreference.UserPreferenceCommand; +import yeonba.be.user.repository.vocalrange.VocalRangeQuery; +import yeonba.be.util.PasswordEncryptor; +import yeonba.be.util.S3Service; +import yeonba.be.util.SaltGenerator; @Service @RequiredArgsConstructor public class UserService { + private final int JOIN_REWARD_ARROWS = 30; + + private final ProfilePhotoCommand profilePhotoCommand; + private final UserCommand userCommand; + private final UserPreferenceCommand userPreferenceCommand; + + private final AnimalQuery animalQuery; + private final AreaQuery areaQuery; private final ArrowQuery arrowQuery; private final UserQuery userQuery; + private final VocalRangeQuery vocalRangeQuery; + + private final PasswordEncryptor passwordEncryptor; + + private final S3Service s3Service; @Transactional(readOnly = true) public UserProfileResponse getTargetUserProfile(long userId, long targetUserId) { @@ -36,4 +72,106 @@ public UserProfileResponse getTargetUserProfile(long userId, long targetUserId) targetUser.getAnimal().getName(), isAlreadySentArrow); } + + public User saveUser(UserJoinRequest request) { + + // 이미 사용 중인 이메일인지 확인 + if (userQuery.validateUsedEmail(request.getEmail())) { + + throw new GeneralException(JoinException.ALREADY_USED_EMAIL); + } + + // 이미 사용 중인 닉네임인지 확인 + if (userQuery.validateUsedNickname(request.getNickname())) { + + throw new GeneralException(JoinException.ALREADY_USED_NICKNAME); + } + + // 이미 사용 중인 핸드폰 번호인지 확인 + if (userQuery.validateUsedPhoneNumber(request.getPhoneNumber())) { + + throw new GeneralException(JoinException.ALREADY_USED_PHONE_NUMBER); + } + + // 비밀빈호, 비밀번호 확인 값 일치 확인 + String password = request.getPassword(); + String passwordConfirmation = request.getPasswordConfirmation(); + if (!StringUtils.equals(password, passwordConfirmation)) { + + throw new GeneralException(JoinException.PASSWORD_CONFIRMATION_NOT_MATCH); + } + + // 성별 판별 + Gender gender = Gender.from(request.getGender()); + + // 나이 계산 + LocalDate birth = request.getBirth(); + int age = Period.between(birth, LocalDate.now()).getYears(); + + // salt 생성 및 비밀번호 암호화 + String salt = SaltGenerator.generateRandomSalt(); + String encryptedPassword = passwordEncryptor.encrypt(password, salt); + + // 음역대, 동물상, 지역 조회 + VocalRange vocalRange = vocalRangeQuery.findBy(request.getVocalRange()); + Animal animal = animalQuery.findByName(request.getLookAlikeAnimal()); + Area area = areaQuery.findByName(request.getActivityArea()); + + // 사용자 생성 및 저장 + User user = new User( + gender.genderBoolean, + request.getName(), + request.getNickname(), + request.getBirth(), + age, + request.getHeight(), + request.getEmail(), + encryptedPassword, + salt, + request.getPhoneNumber(), + JOIN_REWARD_ARROWS, + request.getPhotoSyncRate(), + request.getBodyType(), + request.getJob(), + request.getMbti(), + vocalRange, + animal, + area); + + return userCommand.save(user); + } + + public void saveProfilePhotos(User user, UserJoinRequest request) { + + List photoFiles = request.getProfilePhotos(); + + List profilePhotoUrls = s3Service.uploadProfilePhotos(photoFiles, user); + List profilePhotos = profilePhotoUrls.stream() + .map(profilePhotoUrl -> new ProfilePhoto(user, profilePhotoUrl)) + .toList(); + + profilePhotoCommand.save(profilePhotos); + user.updateProfilePhotos(profilePhotos); + } + + public void saveUserPreference(User user, UserJoinRequest request) { + + // 선호 음역대, 동물상, 지역 조회 + Animal preferredAnimal = animalQuery.findByName(request.getPreferredAnimal()); + VocalRange preferredVocalRange = vocalRangeQuery.findBy(request.getPreferredVocalRange()); + Area preferredArea = areaQuery.findByName(request.getPreferredArea()); + + UserPreference userPreference = new UserPreference( + request.getPreferredAgeLowerBound(), + request.getPreferredAgeUpperBound(), + request.getPreferredHeightLowerBound(), + request.getPreferredHeightUpperBound(), + request.getMbti(), + request.getBodyType(), + user, + preferredVocalRange, + preferredArea, + preferredAnimal); + userPreferenceCommand.save(userPreference); + } } diff --git a/be/src/main/java/yeonba/be/util/JwtUtil.java b/be/src/main/java/yeonba/be/util/JwtUtil.java new file mode 100644 index 00000000..9ce61c2f --- /dev/null +++ b/be/src/main/java/yeonba/be/util/JwtUtil.java @@ -0,0 +1,53 @@ +package yeonba.be.util; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import yeonba.be.user.entity.User; + +@Component +public class JwtUtil { + + private final Duration ACCESS_TOKEN_DURATION = Duration.of(8, ChronoUnit.HOURS); + private final Duration REFRESH_TOKEN_DURATION = Duration.of(10, ChronoUnit.DAYS); + + @Value("${JWT_SECRET}") + private String jwtSecret; + + public String generateAccessToken(User user, Date generatedAt) { + + Date expiredAt = getExpiredAt(generatedAt, ACCESS_TOKEN_DURATION); + + return generateUserJwt(user, generatedAt, expiredAt); + } + + public String generateRefreshToken(User user, Date generatedAt) { + + Date expiredAt = getExpiredAt(generatedAt, REFRESH_TOKEN_DURATION); + + return generateUserJwt(user, generatedAt, expiredAt); + } + + private Date getExpiredAt(Date generatedAt, Duration duration) { + + Instant instant = generatedAt.toInstant() + .plusMillis(duration.toMillis()); + + return Date.from(instant); + } + + private String generateUserJwt(User user, Date issuedAt, Date generatedAt) { + + return Jwts.builder() + .setIssuedAt(issuedAt) + .setExpiration(generatedAt) + .claim("userId", user.getId()) + .signWith(SignatureAlgorithm.HS256, jwtSecret) + .compact(); + } +} diff --git a/be/src/main/java/yeonba/be/mypage/util/PasswordEncryptor.java b/be/src/main/java/yeonba/be/util/PasswordEncryptor.java similarity index 92% rename from be/src/main/java/yeonba/be/mypage/util/PasswordEncryptor.java rename to be/src/main/java/yeonba/be/util/PasswordEncryptor.java index 8a4d3156..f6a46bea 100644 --- a/be/src/main/java/yeonba/be/mypage/util/PasswordEncryptor.java +++ b/be/src/main/java/yeonba/be/util/PasswordEncryptor.java @@ -1,4 +1,4 @@ -package yeonba.be.mypage.util; +package yeonba.be.util; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -9,7 +9,7 @@ public class PasswordEncryptor { public String encrypt(String password, String salt) { - String encryptedPassword = ""; + String encryptedPassword; try { MessageDigest md = MessageDigest.getInstance("SHA-256"); diff --git a/be/src/main/java/yeonba/be/util/S3Service.java b/be/src/main/java/yeonba/be/util/S3Service.java new file mode 100644 index 00000000..97a09e24 --- /dev/null +++ b/be/src/main/java/yeonba/be/util/S3Service.java @@ -0,0 +1,78 @@ +package yeonba.be.util; + +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import yeonba.be.exception.GeneralException; +import yeonba.be.exception.UtilException; +import yeonba.be.user.entity.User; + +@Service +@RequiredArgsConstructor +public class S3Service { + + private final String ALLOWED_PROFILE_PHOTO_EXTENSION_REGEX = "^(.+)\\.(jpg|jpeg|png)$"; + + private final S3Client s3Client; + + @Value("${S3_BUCKET_NAME}") + private String bucketName; + + /* + 프로필 사진 업로드는 다음 과정을 거쳐 이뤄진다. + 1. 사진 파일들 확장자 검증 + 2. 파일 식별을 위한 키 생성, profilephoto/{userId}-{photo idx} 형식 + 3. 사진 파일 업로드 요청 생성 및 업로드 수행 + */ + public List uploadProfilePhotos(List profilePhotos, User user) { + + if (!validateProfilePhotosExtensions(profilePhotos)) { + + throw new GeneralException(UtilException.NOT_ALLOWED_IMAGE_FILE_EXTENSION); + } + + List uploadedProfilePhotosUrls = new ArrayList<>(); + long userId = user.getId(); + + for (int profilePhotoIdx = 0; profilePhotoIdx < profilePhotos.size(); profilePhotoIdx++) { + + MultipartFile profilePhoto = profilePhotos.get(profilePhotoIdx); + String key = String.format("profilephoto/%d-%d", userId, profilePhotoIdx); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentDisposition("inline") + .contentType(profilePhoto.getContentType()) + .build(); + + try { + s3Client.putObject(putObjectRequest, + RequestBody.fromInputStream(profilePhoto.getInputStream(), + profilePhoto.getSize())); + uploadedProfilePhotosUrls.add(key); + } catch (Exception e) { + + String message = String.format("Failed to upload file : %s", + profilePhoto.getOriginalFilename()); + + throw new IllegalStateException(message, e); + } + } + + return uploadedProfilePhotosUrls; + } + + private boolean validateProfilePhotosExtensions(List profilePhotos) { + + return profilePhotos.stream() + .allMatch(profilePhoto -> profilePhoto.getOriginalFilename() + .matches(ALLOWED_PROFILE_PHOTO_EXTENSION_REGEX)); + } +} diff --git a/be/src/main/java/yeonba/be/util/SaltGenerator.java b/be/src/main/java/yeonba/be/util/SaltGenerator.java new file mode 100644 index 00000000..12c4bf81 --- /dev/null +++ b/be/src/main/java/yeonba/be/util/SaltGenerator.java @@ -0,0 +1,27 @@ +package yeonba.be.util; + +import java.security.SecureRandom; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.codec.binary.Base64; + +/* +- 32바이트 길이의 salt만으로 대부분의 보안적 위협은 커버 가능 +- 이하 로직을 통해 생성되는 salt 문자열의 길이는 약 44자 + */ + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SaltGenerator { + + private static final int SALT_BYTE_LENGTH = 32; + + public static String generateRandomSalt() { + + SecureRandom random = new SecureRandom(); + + byte[] salt = new byte[SALT_BYTE_LENGTH]; + random.nextBytes(salt); + + return Base64.encodeBase64String(salt); + } +}