diff --git a/.github/workflows/github-actions-server.yaml b/.github/workflows/github-actions-server.yaml index 9e3a4b1b..2d2c4fdd 100644 --- a/.github/workflows/github-actions-server.yaml +++ b/.github/workflows/github-actions-server.yaml @@ -40,8 +40,6 @@ jobs: - name: Create application.yml, *.sql and log4jdbc.lof4j2.properties for test run: | mkdir -p ./src/test/resources/data - touch ./src/test/resources/application-test.yml - echo -e "${{secrets.APPLICATION_TEST}}" | base64 --decode > ./src/test/resources/application-test.yml touch ./src/test/resources/data/data.sql echo -e "${{secrets.DATA_SQL}}" | base64 --decode > ./src/test/resources/data/data.sql touch ./src/test/resources/data/schema.sql @@ -49,13 +47,6 @@ jobs: touch ./src/test/resources/log4jdbc.log4j2.properties echo -e "${{secrets.LOG4JDBC}}" | base64 --decode > ./src/test/resources/log4jdbc.log4j2.properties - - name: Upload application-test.yml for test - uses: actions/upload-artifact@v3 - with: - name: application-test.yml - path: ./src/test/resources/application-test.yml - retention-days: 1 - - name: Upload data.sql for test uses: actions/upload-artifact@v3 with: diff --git a/build.gradle b/build.gradle index e26c47f9..293a30dd 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,10 @@ configurations { compileOnly { extendsFrom annotationProcessor } + all { + exclude group: 'commons-logging', module: 'commons-logging' + exclude group: 'org.slf4j', module: 'slf4j-simple' + } } repositories { @@ -22,6 +26,7 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-websocket' @@ -30,17 +35,25 @@ dependencies { implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' implementation 'com.mysql:mysql-connector-j' implementation group: 'org.bgee.log4jdbc-log4j2', name:'log4jdbc-log4j2-jdbc4.1', version: '1.16' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation group: 'it.ozimov', name: 'embedded-redis', version: '0.7.3' implementation 'io.jsonwebtoken:jjwt-api:0.11.2' implementation 'io.jsonwebtoken:jjwt-impl:0.11.2' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2' + implementation platform("org.springframework.cloud:spring-cloud-dependencies:2023.0.0") + implementation "org.springframework.cloud:spring-cloud-starter-openfeign" + implementation 'com.google.code.gson:gson' implementation 'org.springframework.boot:spring-boot-starter-validation' - compileOnly 'org.projectlombok:lombok' + implementation 'com.fasterxml.uuid:java-uuid-generator:4.3.0' + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' @@ -48,6 +61,9 @@ dependencies { testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.3' testImplementation 'com.h2database:h2' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + } tasks.named('test') { diff --git a/src/main/java/com/swyp3/babpool/domain/profile/api/ProfileApi.java b/src/main/java/com/swyp3/babpool/domain/profile/api/ProfileApi.java new file mode 100644 index 00000000..0ee26c80 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/api/ProfileApi.java @@ -0,0 +1,26 @@ +package com.swyp3.babpool.domain.profile.api; + +import com.swyp3.babpool.domain.profile.api.request.ProfileUpdateRequest; +import com.swyp3.babpool.domain.profile.application.ProfileService; +import com.swyp3.babpool.domain.profile.application.response.ProfileUpdateResponse; +import com.swyp3.babpool.global.common.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class ProfileApi { + + private final ProfileService profileService; + + @PostMapping("/api/profile/card") + public ApiResponse updateProfileCard(@RequestAttribute(value = "userId") Long userId, + @RequestPart(value = "profileImageFile") MultipartFile multipartFile, + @RequestPart(value = "profileInfo") ProfileUpdateRequest profileUpdateRequest) { + profileService.uploadProfileImage(userId, multipartFile); + ProfileUpdateResponse profileResponse = profileService.saveProfileInfo(userId, profileUpdateRequest); + return ApiResponse.ok(profileResponse); + } + +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/api/ProfilePermitApi.java b/src/main/java/com/swyp3/babpool/domain/profile/api/ProfilePermitApi.java new file mode 100644 index 00000000..4c3b29a1 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/api/ProfilePermitApi.java @@ -0,0 +1,28 @@ +package com.swyp3.babpool.domain.profile.api; + +import com.swyp3.babpool.domain.profile.api.request.ProfileListRequest; +import com.swyp3.babpool.domain.profile.application.ProfileService; +import com.swyp3.babpool.domain.profile.application.response.ProfileResponse; +import com.swyp3.babpool.global.common.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class ProfilePermitApi { + + private final ProfileService profileService; + + @GetMapping("/api/profile/list") + public ApiResponse> getProfileList(@RequestParam String searchTerm, + @RequestParam List keywords) { + return ApiResponse.ok(profileService.getProfileListInConditionsOf(ProfileListRequest.builder() + .searchTerm(searchTerm) + .keywords(keywords) + .build())); + } +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/api/request/ProfileListRequest.java b/src/main/java/com/swyp3/babpool/domain/profile/api/request/ProfileListRequest.java new file mode 100644 index 00000000..4cd8766d --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/api/request/ProfileListRequest.java @@ -0,0 +1,21 @@ +package com.swyp3.babpool.domain.profile.api.request; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +import java.util.List; + +@Getter +@ToString +public class ProfileListRequest { + + private String searchTerm; + private List keywords; + + @Builder + public ProfileListRequest(String searchTerm, List keywords) { + this.searchTerm = searchTerm; + this.keywords = keywords; + } +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/api/request/ProfileUpdateRequest.java b/src/main/java/com/swyp3/babpool/domain/profile/api/request/ProfileUpdateRequest.java new file mode 100644 index 00000000..b55feb1b --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/api/request/ProfileUpdateRequest.java @@ -0,0 +1,23 @@ +package com.swyp3.babpool.domain.profile.api.request; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@ToString +@Getter +public class ProfileUpdateRequest { + + private String profileIntro; + private String profileContents; + private String profileContactPhone; + private String profileContactChat; + + @Builder + public ProfileUpdateRequest(String profileIntro, String profileContents, String profileContactPhone, String profileContactChat) { + this.profileIntro = profileIntro; + this.profileContents = profileContents; + this.profileContactPhone = profileContactPhone; + this.profileContactChat = profileContactChat; + } +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/application/ProfileService.java b/src/main/java/com/swyp3/babpool/domain/profile/application/ProfileService.java new file mode 100644 index 00000000..93217a7f --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/application/ProfileService.java @@ -0,0 +1,18 @@ +package com.swyp3.babpool.domain.profile.application; + +import com.swyp3.babpool.domain.profile.api.request.ProfileListRequest; +import com.swyp3.babpool.domain.profile.api.request.ProfileUpdateRequest; +import com.swyp3.babpool.domain.profile.application.response.ProfileResponse; +import com.swyp3.babpool.domain.profile.application.response.ProfileUpdateResponse; +import com.swyp3.babpool.global.common.response.ApiResponse; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface ProfileService { + List getProfileListInConditionsOf(ProfileListRequest profileListRequest); + + String uploadProfileImage(Long userId, MultipartFile multipartFile); + + ProfileUpdateResponse saveProfileInfo(Long userId, ProfileUpdateRequest profileUpdateRequest); +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/application/ProfileServiceImpl.java b/src/main/java/com/swyp3/babpool/domain/profile/application/ProfileServiceImpl.java new file mode 100644 index 00000000..e6d59b4d --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/application/ProfileServiceImpl.java @@ -0,0 +1,45 @@ +package com.swyp3.babpool.domain.profile.application; + +import com.swyp3.babpool.domain.profile.api.request.ProfileListRequest; +import com.swyp3.babpool.domain.profile.api.request.ProfileUpdateRequest; +import com.swyp3.babpool.domain.profile.application.response.ProfileResponse; +import com.swyp3.babpool.domain.profile.application.response.ProfileUpdateResponse; +import com.swyp3.babpool.domain.profile.dao.ProfileRepository; +import com.swyp3.babpool.domain.profile.domain.Profile; +import com.swyp3.babpool.infra.s3.application.AwsS3Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProfileServiceImpl implements ProfileService{ + + private final AwsS3Provider awsS3Provider; + private final ProfileRepository profileRepository; + + @Override + public List getProfileListInConditionsOf(ProfileListRequest profileListRequest) { + return profileRepository.findByUserIdAndSearchTermAndKeywords(profileListRequest).stream() + .map(ProfileResponse::from) + .toList(); + } + + @Override + public String uploadProfileImage(Long userId, MultipartFile multipartFile) { + String uploadedImageUrl = awsS3Provider.uploadImage(multipartFile); + profileRepository.saveProfileImageUrl(Profile.builder() + .userId(userId) + .profileImageUrl(uploadedImageUrl) + .build()); + return uploadedImageUrl; + } + + @Override + public ProfileUpdateResponse saveProfileInfo(Long userId, ProfileUpdateRequest profileUpdateRequest) { + return null; + } + +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/application/response/ProfileResponse.java b/src/main/java/com/swyp3/babpool/domain/profile/application/response/ProfileResponse.java new file mode 100644 index 00000000..5bf63ed7 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/application/response/ProfileResponse.java @@ -0,0 +1,39 @@ +package com.swyp3.babpool.domain.profile.application.response; + +import com.swyp3.babpool.domain.profile.domain.Profile; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@ToString +@Getter +public class ProfileResponse { + + private Long profileId; + private String profileImageUrl; + private String profileIntro; + private String profileContents; + private String profileContactPhone; + private String profileContactChat; + + @Builder + public ProfileResponse(Long profileId, String profileImageUrl, String profileIntro, String profileContents, String profileContactPhone, String profileContactChat) { + this.profileId = profileId; + this.profileImageUrl = profileImageUrl; + this.profileIntro = profileIntro; + this.profileContents = profileContents; + this.profileContactPhone = profileContactPhone; + this.profileContactChat = profileContactChat; + } + + public static ProfileResponse from(Profile profile) { + return ProfileResponse.builder() + .profileId(profile.getProfileId()) + .profileImageUrl(profile.getProfileImageUrl()) + .profileIntro(profile.getProfileIntro()) + .profileContents(profile.getProfileContents()) + .profileContactPhone(profile.getProfileContactPhone()) + .profileContactChat(profile.getProfileContactChat()) + .build(); + } +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/application/response/ProfileUpdateResponse.java b/src/main/java/com/swyp3/babpool/domain/profile/application/response/ProfileUpdateResponse.java new file mode 100644 index 00000000..451031d4 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/application/response/ProfileUpdateResponse.java @@ -0,0 +1,25 @@ +package com.swyp3.babpool.domain.profile.application.response; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@ToString +@Getter +public class ProfileUpdateResponse { + + private String profileImageUrl; + private String profileIntro; + private String profileContents; + private String profileContactPhone; + private String profileContactChat; + + @Builder + public ProfileUpdateResponse(String profileImageUrl, String profileIntro, String profileContents, String profileContactPhone, String profileContactChat) { + this.profileImageUrl = profileImageUrl; + this.profileIntro = profileIntro; + this.profileContents = profileContents; + this.profileContactPhone = profileContactPhone; + this.profileContactChat = profileContactChat; + } +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/dao/ProfileRepository.java b/src/main/java/com/swyp3/babpool/domain/profile/dao/ProfileRepository.java new file mode 100644 index 00000000..e17b9529 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/dao/ProfileRepository.java @@ -0,0 +1,16 @@ +package com.swyp3.babpool.domain.profile.dao; + +import com.swyp3.babpool.domain.profile.api.request.ProfileListRequest; +import com.swyp3.babpool.domain.profile.domain.Profile; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface ProfileRepository { + + void saveProfileImageUrl(Profile profile); + + List findByUserIdAndSearchTermAndKeywords(ProfileListRequest profileListRequest); +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/domain/Profile.java b/src/main/java/com/swyp3/babpool/domain/profile/domain/Profile.java new file mode 100644 index 00000000..ae6499dc --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/domain/Profile.java @@ -0,0 +1,31 @@ +package com.swyp3.babpool.domain.profile.domain; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@ToString +@Getter +public class Profile { + + private Long profileId; + private Long userId; + private String profileImageUrl; + private String profileIntro; + private String profileContents; + private String profileContactPhone; + private String profileContactChat; + private Boolean profileActiveFlag; + + @Builder + public Profile(Long profileId, Long userId, String profileImageUrl, String profileIntro, String profileContents, String profileContactPhone, String profileContactChat, Boolean profileActiveFlag) { + this.profileId = profileId; + this.userId = userId; + this.profileImageUrl = profileImageUrl; + this.profileIntro = profileIntro; + this.profileContents = profileContents; + this.profileContactPhone = profileContactPhone; + this.profileContactChat = profileContactChat; + this.profileActiveFlag = profileActiveFlag; + } +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/exception/ProfileException.java b/src/main/java/com/swyp3/babpool/domain/profile/exception/ProfileException.java new file mode 100644 index 00000000..07824f60 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/exception/ProfileException.java @@ -0,0 +1,13 @@ +package com.swyp3.babpool.domain.profile.exception; + +import com.swyp3.babpool.domain.profile.exception.errorcode.ProfileErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ProfileException extends RuntimeException{ + + private final ProfileErrorCode errorCode; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/exception/errorcode/ProfileErrorCode.java b/src/main/java/com/swyp3/babpool/domain/profile/exception/errorcode/ProfileErrorCode.java new file mode 100644 index 00000000..37abd810 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/exception/errorcode/ProfileErrorCode.java @@ -0,0 +1,16 @@ +package com.swyp3.babpool.domain.profile.exception.errorcode; + +import com.swyp3.babpool.global.common.exception.errorcode.CustomErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ProfileErrorCode implements CustomErrorCode { + + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/domain/profile/exception/handler/ProfileExceptionHandler.java b/src/main/java/com/swyp3/babpool/domain/profile/exception/handler/ProfileExceptionHandler.java new file mode 100644 index 00000000..ec01b196 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/profile/exception/handler/ProfileExceptionHandler.java @@ -0,0 +1,19 @@ +package com.swyp3.babpool.domain.profile.exception.handler; + +import com.swyp3.babpool.domain.profile.exception.ProfileException; +import com.swyp3.babpool.global.common.response.ApiErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class ProfileExceptionHandler { + + @ExceptionHandler + protected ApiErrorResponse handleProfileException(ProfileException exception){ + log.error("ProfileException getProfileErrorCode() >> "+exception.getErrorCode()); + log.error("ProfileException getMessage() >> "+exception.getMessage()); + return ApiErrorResponse.of(exception.getErrorCode()); + } +} diff --git a/src/main/java/com/swyp3/babpool/domain/user/api/UserSignOutApi.java b/src/main/java/com/swyp3/babpool/domain/user/api/UserSignOutApi.java new file mode 100644 index 00000000..b45e79a8 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/user/api/UserSignOutApi.java @@ -0,0 +1,30 @@ +package com.swyp3.babpool.domain.user.api; + +import com.swyp3.babpool.global.jwt.application.JwtServiceImpl; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Arrays; +import java.util.Optional; + +@RestController +@RequiredArgsConstructor +public class UserSignOutApi { + + private final JwtServiceImpl jwtService; + + @PostMapping("/api/user/sign/out") + public void signOut(HttpServletRequest request){ + Cookie[] cookies = request.getCookies(); + if(cookies != null){ + Optional optionalCookie = Arrays.stream(cookies) + .filter(cookie -> cookie.getName().equals("userRefreshToken")) + .findAny(); + optionalCookie.ifPresent(cookie -> jwtService.logout(cookie.getValue())); + } + } + +} diff --git a/src/main/java/com/swyp3/babpool/domain/user/dao/UserRepository.java b/src/main/java/com/swyp3/babpool/domain/user/dao/UserRepository.java index cdf6d43a..b77eb09e 100644 --- a/src/main/java/com/swyp3/babpool/domain/user/dao/UserRepository.java +++ b/src/main/java/com/swyp3/babpool/domain/user/dao/UserRepository.java @@ -1,7 +1,17 @@ package com.swyp3.babpool.domain.user.dao; +import com.swyp3.babpool.domain.user.domain.User; +import com.swyp3.babpool.infra.auth.AuthPlatform; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; @Mapper public interface UserRepository { + void save(User user); + + Long findUserIdByPlatformAndPlatformId(@Param("platformName") AuthPlatform authPlatform,@Param("platformId") String platformId); + + User findById(Long userId); } diff --git a/src/main/java/com/swyp3/babpool/domain/user/domain/User.java b/src/main/java/com/swyp3/babpool/domain/user/domain/User.java index fd62ba18..89dbd796 100644 --- a/src/main/java/com/swyp3/babpool/domain/user/domain/User.java +++ b/src/main/java/com/swyp3/babpool/domain/user/domain/User.java @@ -1,4 +1,33 @@ package com.swyp3.babpool.domain.user.domain; +import com.swyp3.babpool.infra.auth.response.AuthMemberResponse; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor public class User { -} + private Long userId; + private String userEmail; + private UserStatus userStatus; + private UserRole userRole; + private String userGrade; + private String userNickName; + private LocalDateTime userCreateDate; + private LocalDateTime userModifyDate; + + // 생성자에 @Builder 적용 + @Builder + public User(String email, String nickName) { + this.userEmail= email; + this.userStatus = UserStatus.ACTIVE; + this.userRole = UserRole.USER; + this.userGrade = "none"; + this.userNickName = nickName; + this.userCreateDate = LocalDateTime.now(); + this.userModifyDate = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp3/babpool/domain/user/domain/UserRole.java b/src/main/java/com/swyp3/babpool/domain/user/domain/UserRole.java new file mode 100644 index 00000000..68c672b3 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/user/domain/UserRole.java @@ -0,0 +1,6 @@ +package com.swyp3.babpool.domain.user.domain; + +public enum UserRole { + USER, + ADMIN +} diff --git a/src/main/java/com/swyp3/babpool/domain/user/domain/UserStatus.java b/src/main/java/com/swyp3/babpool/domain/user/domain/UserStatus.java new file mode 100644 index 00000000..1d94fbc2 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/domain/user/domain/UserStatus.java @@ -0,0 +1,7 @@ +package com.swyp3.babpool.domain.user.domain; + +public enum UserStatus { + EXIT, + ACTIVE, + BAN +} diff --git a/src/main/java/com/swyp3/babpool/global/common/exception/errorcode/BabpoolErrorCode.java b/src/main/java/com/swyp3/babpool/global/common/exception/errorcode/BabpoolErrorCode.java index 0675da3f..27db7541 100644 --- a/src/main/java/com/swyp3/babpool/global/common/exception/errorcode/BabpoolErrorCode.java +++ b/src/main/java/com/swyp3/babpool/global/common/exception/errorcode/BabpoolErrorCode.java @@ -5,6 +5,7 @@ public enum BabpoolErrorCode implements CustomErrorCode{ BABPOOL_ERROR_CODE(HttpStatus.INTERNAL_SERVER_ERROR, "Babpool Error Code"), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "올바르지 않은 요청 내용입니다."), ; private final HttpStatus httpStatus; @@ -17,11 +18,11 @@ public enum BabpoolErrorCode implements CustomErrorCode{ @Override public HttpStatus getHttpStatus() { - return null; + return httpStatus; } @Override public String getMessage() { - return null; + return message; } } diff --git a/src/main/java/com/swyp3/babpool/global/common/exception/handler/BaseExceptionHandler.java b/src/main/java/com/swyp3/babpool/global/common/exception/handler/BaseExceptionHandler.java new file mode 100644 index 00000000..39678493 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/common/exception/handler/BaseExceptionHandler.java @@ -0,0 +1,22 @@ +package com.swyp3.babpool.global.common.exception.handler; + +import com.swyp3.babpool.global.common.exception.errorcode.BabpoolErrorCode; +import com.swyp3.babpool.global.common.response.ApiErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class BaseExceptionHandler { + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ApiErrorResponse handle_MethodArgumentNotValidException(MethodArgumentNotValidException exception) { + log.error("MethodArgumentNotValidException getmessage() >> "+exception.getMessage()); + return ApiErrorResponse.of(BabpoolErrorCode.INVALID_REQUEST); + } +} diff --git a/src/main/java/com/swyp3/babpool/global/common/response/ApiResponseWithCookie.java b/src/main/java/com/swyp3/babpool/global/common/response/ApiResponseWithCookie.java index 32876658..0b0b4956 100644 --- a/src/main/java/com/swyp3/babpool/global/common/response/ApiResponseWithCookie.java +++ b/src/main/java/com/swyp3/babpool/global/common/response/ApiResponseWithCookie.java @@ -1,61 +1,59 @@ package com.swyp3.babpool.global.common.response; +import com.swyp3.babpool.global.common.response.config.ApiResponseConfigProperties; import lombok.Builder; import lombok.Getter; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; @Getter public class ApiResponseWithCookie { + private static String domain = ApiResponseConfigProperties.getDomain(); + private static Integer refreshTokenExpireDays = Integer.valueOf(ApiResponseConfigProperties.getRefreshTokenExpireDays()); private final LocalDateTime timestamp = LocalDateTime.now(); private int code; private HttpStatus status; private String message; private T data; - private List cookies; + private ResponseCookie cookie; - public ApiResponseWithCookie(HttpStatus status, String message, T data, List cookies) { + + public ApiResponseWithCookie(HttpStatus status, String message, T data, ResponseCookie cookie) { this.code = status.value(); this.status = status; this.message = message; this.data = data; - this.cookies = cookies; - } - - @Builder - public static ApiResponseWithCookie ofMultipleCookies(HttpStatus status, String message, T data, Map cookiesKeyValue, String clientDomain, Integer times) { - List cookies = cookiesKeyValue.entrySet().stream() - .map(entry -> createCookie(entry.getKey(), entry.getValue(), clientDomain, times)) - .toList(); - return new ApiResponseWithCookie<>(status, message, data, cookies); + this.cookie = cookie; } @Builder - public static ApiResponseWithCookie of(HttpStatus status, String message, T data, String cookieKey, String cookieValue, String clientDomain, Integer times) { - return new ApiResponseWithCookie<>(status, message, data, List.of(createCookie(cookieKey, cookieValue, clientDomain, times))); + public static ApiResponseWithCookie ofCookie(HttpStatus status, String message, T data, String key, String value, boolean httpOnlyFlag, Integer expireDays) { + ResponseCookie cookie = ResponseCookie.from(key, value) + .domain(domain) + .httpOnly(httpOnlyFlag) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(60 * 60 * 24 * expireDays) + .build(); + return new ApiResponseWithCookie<>(status, message, data, cookie); } @Builder - public static ApiResponseWithCookie ofRefreshToken(HttpStatus status, String message, T data, String refreshToken, String clientDomain, Integer times) { - List cookies = List.of(createCookie("refreshToken", refreshToken, clientDomain, times)); - return new ApiResponseWithCookie<>(status, message, data, cookies); - } - - private static ResponseCookie createCookie(String key, String value, String domain, Integer times) { - return ResponseCookie.from(key, value) + public static ApiResponseWithCookie ofRefreshToken(HttpStatus status, String message, T data, String refreshToken) { + ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) .domain(domain) .httpOnly(true) .secure(true) .sameSite("None") .path("/") - .maxAge(60 * 60 * times) + .maxAge(60 * 60 * 24 * refreshTokenExpireDays) .build(); + return new ApiResponseWithCookie<>(status, message, data, cookie); } } diff --git a/src/main/java/com/swyp3/babpool/global/common/response/config/ApiResponseConfigProperties.java b/src/main/java/com/swyp3/babpool/global/common/response/config/ApiResponseConfigProperties.java new file mode 100644 index 00000000..0b20aab2 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/common/response/config/ApiResponseConfigProperties.java @@ -0,0 +1,30 @@ +package com.swyp3.babpool.global.common.response.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class ApiResponseConfigProperties { + + private static String refreshTokenExpireDays; + private static String domain; + + @Value("${property.jwt.refreshTokenExpireDays}") + public void setRefreshTokenExpireDaysProperty(String fromProperty) { + ApiResponseConfigProperties.refreshTokenExpireDays = fromProperty; + } + + public static String getRefreshTokenExpireDays() { + return refreshTokenExpireDays; + } + + @Value("${property.cookie.domain}") + public void setCookieDomainProperty(String fromProperty) { + ApiResponseConfigProperties.domain = fromProperty; + } + + public static String getDomain() { + return domain; + } + +} diff --git a/src/main/java/com/swyp3/babpool/global/config/FeignClientConfig.java b/src/main/java/com/swyp3/babpool/global/config/FeignClientConfig.java new file mode 100644 index 00000000..026699ad --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/config/FeignClientConfig.java @@ -0,0 +1,10 @@ +package com.swyp3.babpool.global.config; + +import com.swyp3.babpool.BabpoolApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableFeignClients(basePackageClasses = BabpoolApplication.class) +public class FeignClientConfig { +} diff --git a/src/main/java/com/swyp3/babpool/global/config/WebMvcInterceptJwtConfig.java b/src/main/java/com/swyp3/babpool/global/config/WebMvcInterceptJwtConfig.java new file mode 100644 index 00000000..b7885398 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/config/WebMvcInterceptJwtConfig.java @@ -0,0 +1,26 @@ +package com.swyp3.babpool.global.config; + +import com.swyp3.babpool.global.jwt.JwtTokenInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcInterceptJwtConfig implements WebMvcConfigurer { + + private final JwtTokenInterceptor jwtTokenInterceptor; + private static final String[] EXCLUDE_PATHS = { + "/api/user/sign/in", "/api/user/sign/up", "/api/user/sign/out", "/api/token/access/refresh", + "/api/test/connection", "/api/test/jwt/permitted", "/api/test/uuid", "/api/test/jwt/tokens", "/api/test/image/upload", "/api/test/image/delete", "/api/test/cookie", + "/api/profile/list" + }; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(jwtTokenInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns(EXCLUDE_PATHS); + } +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/JwtAuthenticator.java b/src/main/java/com/swyp3/babpool/global/jwt/JwtAuthenticator.java new file mode 100644 index 00000000..8d976e0c --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/JwtAuthenticator.java @@ -0,0 +1,31 @@ +package com.swyp3.babpool.global.jwt; + +import com.swyp3.babpool.global.uuid.dao.UserUuidRepository; +import com.swyp3.babpool.global.uuid.exception.UuidErrorCode; +import com.swyp3.babpool.global.uuid.exception.UuidException; +import com.swyp3.babpool.global.uuid.util.UuidResolver; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticator { + + private final JwtTokenizer jwtTokenizer; + private final UuidResolver uuidResolver; + private final UserUuidRepository userUuidRepository; + + public Claims authenticate(String accessToken) { + return jwtTokenizer.parseAccessToken(accessToken); + } + + public Long jwtTokenUserIdResolver(String userUuid) { + return userUuidRepository.findByUserUuIdBytes(uuidResolver.parseUuidToBytes(UUID.fromString(userUuid))).orElseThrow( + () -> new UuidException(UuidErrorCode.NOT_FOUND_USER_UUID, + "Not found user id with uuid, while JwtAuthenticator request to UserUuidRepository")) + .getUserId(); + } +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/JwtTokenInterceptor.java b/src/main/java/com/swyp3/babpool/global/jwt/JwtTokenInterceptor.java new file mode 100644 index 00000000..e8a27e1b --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/JwtTokenInterceptor.java @@ -0,0 +1,79 @@ +package com.swyp3.babpool.global.jwt; + +import com.swyp3.babpool.global.jwt.exception.BadCredentialsException; +import com.swyp3.babpool.global.jwt.exception.errorcode.JwtExceptionErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.IOException; +import io.jsonwebtoken.security.SignatureException; +import io.jsonwebtoken.security.WeakKeyException; +import io.netty.util.internal.StringUtil; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; + + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenInterceptor implements HandlerInterceptor { + + private final JwtAuthenticator jwtAuthenticator; + public static final String AUTHORIZATION = "Authorization"; + public static final String BEARER = "Bearer"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException { + String accessToken = StringUtil.EMPTY_STRING; + Claims authenticatedClaims = null; + try { + accessToken = getAccessTokenFrom(request); + if (StringUtils.hasText(accessToken)) { + authenticatedClaims = jwtAuthenticator.authenticate(accessToken); + } + if (!authenticatedClaims.isEmpty()){ + Long userId = jwtAuthenticator.jwtTokenUserIdResolver(authenticatedClaims.getSubject()); + request.setAttribute("userId", userId); + } + } catch (NullPointerException | IllegalStateException e) { + log.error("Not found Token // token : {}", accessToken); + throw new BadCredentialsException(JwtExceptionErrorCode.NOT_FOUND_TOKEN, "throw new not found token exception"); + } catch (SecurityException | MalformedJwtException | SignatureException | WeakKeyException e) { + log.error("Invalid Token // token : {}", accessToken); + throw new BadCredentialsException(JwtExceptionErrorCode.INVALID_TOKEN, "throw new invalid token exception"); + } catch (ExpiredJwtException e) { + log.error("EXPIRED Token // token : {}", accessToken); + throw new BadCredentialsException(JwtExceptionErrorCode.EXPIRED_TOKEN, "throw new expired token exception"); + } catch (UnsupportedJwtException e) { + log.error("Unsupported Token // token : {}", accessToken); + throw new BadCredentialsException(JwtExceptionErrorCode.UNSUPPORTED_TOKEN, "throw new unsupported token exception"); + } catch (Exception e) { + log.error("===================================================="); + log.error("Babpool JwtTokenInterceptor - preHandle() 오류 발생"); + log.error("token : {}", accessToken); + log.error("Exception Message : {}", e.getMessage()); + log.error("Exception StackTrace : {"); + e.printStackTrace(); + log.error("}"); + log.error("===================================================="); + throw new BadCredentialsException(JwtExceptionErrorCode.NOT_FOUND_TOKEN, "throw new exception"); + } + return true; + } + + + private String getAccessTokenFrom(HttpServletRequest request) { + String authorization = request.getHeader(AUTHORIZATION); + if (StringUtils.hasText(authorization) && authorization.startsWith(BEARER)) { + return authorization.split(" ")[1]; + } + return null; + } +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/JwtTokenizer.java b/src/main/java/com/swyp3/babpool/global/jwt/JwtTokenizer.java new file mode 100644 index 00000000..eb637f33 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/JwtTokenizer.java @@ -0,0 +1,81 @@ +package com.swyp3.babpool.global.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; +import java.util.List; + +/**@apiNote JwtTokenizer is a class that provides a token for the user. + * This class is used to create, validate, and (extract information) from the JWT token. + */ +@Slf4j +@Component +public class JwtTokenizer { + + private final byte[] accessSecret; + private final byte[] refreshSecret; + + public final static Long ACCESS_TOKEN_EXPIRE_COUNT = 1 * 24 * 60 * 60 * 1000L; // 1 day + public final static Long REFRESH_TOKEN_EXPIRE_COUNT = 7 * 24 * 60 * 60 * 1000L; // 7 days + + public JwtTokenizer(@Value("${property.jwt.secretKey}") String accessSecret, @Value("${property.jwt.refreshKey}") String refreshSecret) { + this.accessSecret = accessSecret.getBytes(); + this.refreshSecret = refreshSecret.getBytes(); + } + + + public String createAccessToken(String uuid, List roles) { + return createToken(uuid, roles, ACCESS_TOKEN_EXPIRE_COUNT, accessSecret); + } + + + public String createRefreshToken(String uuid, List roles) { + return createToken(uuid, roles, REFRESH_TOKEN_EXPIRE_COUNT, refreshSecret); + } + + + private String createToken(String uuid, List roles, Long expire, byte[] secretKey) { + Claims claims = Jwts.claims().setSubject(uuid); + claims.put("roles", roles); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date()) + .setExpiration(new Date(new Date().getTime() + expire)) + .signWith(getSigningKey(secretKey)) + .compact(); + } + + public Claims parseAccessToken(String accessToken) { + return parseToken(accessToken, accessSecret); + } + + public Claims parseRefreshToken(String refreshToken) { + return parseToken(refreshToken, refreshSecret); + } + + public Claims parseToken(String token, byte[] secretKey) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey(secretKey)) + .build() + .parseClaimsJws(token) + .getBody(); + } + + /**@apiNote getSigningKey is a method that returns a signing key from Secret. + * This method is used to return a signing key for the user. + * @param secretKey + * @return Key + */ + private Key getSigningKey(byte[] secretKey) { + return Keys.hmacShaKeyFor(secretKey); + } + + +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/api/JwtApiPermitted.java b/src/main/java/com/swyp3/babpool/global/jwt/api/JwtApiPermitted.java new file mode 100644 index 00000000..c78ce010 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/api/JwtApiPermitted.java @@ -0,0 +1,33 @@ +package com.swyp3.babpool.global.jwt.api; + +import com.swyp3.babpool.global.common.response.ApiResponse; +import com.swyp3.babpool.global.jwt.application.JwtService; +import com.swyp3.babpool.global.jwt.exception.BabpoolJwtException; +import com.swyp3.babpool.global.jwt.exception.errorcode.JwtExceptionErrorCode; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Arrays; +import java.util.Optional; + +@RestController +@RequiredArgsConstructor +public class JwtApiPermitted { + + private final JwtService jwtService; + + @PostMapping("/api/token/access/refresh") + public ApiResponse refreshAccessToken(HttpServletRequest request){ + Optional refreshTokenCookie = Arrays.stream(request.getCookies()) + .filter(cookie -> cookie.getName().equals("refreshToken")) + .findFirst(); + String refreshToken = refreshTokenCookie.map(Cookie::getValue).orElseThrow( + () -> new BabpoolJwtException(JwtExceptionErrorCode.NOT_FOUND_TOKEN, "RefreshToken 이 존재하지 않습니다.")); + return ApiResponse.of(HttpStatus.OK, "로그인 연장 성공", jwtService.extendLoginState(refreshToken)); + } + +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/application/JwtService.java b/src/main/java/com/swyp3/babpool/global/jwt/application/JwtService.java new file mode 100644 index 00000000..cf4f6b18 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/application/JwtService.java @@ -0,0 +1,14 @@ +package com.swyp3.babpool.global.jwt.application; + +import com.swyp3.babpool.global.jwt.application.response.JwtPairDto; + +import java.util.List; + +public interface JwtService { + + JwtPairDto createJwtPair(String userUUID, List roles); + + String extendLoginState(String refreshToken); + + void logout(String refreshToken); +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/application/JwtServiceImpl.java b/src/main/java/com/swyp3/babpool/global/jwt/application/JwtServiceImpl.java new file mode 100644 index 00000000..79deea5a --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/application/JwtServiceImpl.java @@ -0,0 +1,65 @@ +package com.swyp3.babpool.global.jwt.application; + +import com.swyp3.babpool.global.jwt.JwtTokenizer; +import com.swyp3.babpool.global.jwt.application.response.JwtPairDto; +import com.swyp3.babpool.global.jwt.exception.BabpoolJwtException; +import com.swyp3.babpool.global.jwt.exception.errorcode.JwtExceptionErrorCode; +import com.swyp3.babpool.infra.redis.dao.TokenRedisRepository; +import com.swyp3.babpool.infra.redis.domain.TokenForRedis; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class JwtServiceImpl implements JwtService{ + + private final TokenRedisRepository tokenRepository; + private final JwtTokenizer jwtTokenizer; + + @Value("${property.jwt.refreshTokenExpireDays}") + private Integer refreshExpire; + + @Override + public JwtPairDto createJwtPair(String userUUID, List roles) { + JwtPairDto jwtPairDto = JwtPairDto.builder() + .accessToken(jwtTokenizer.createAccessToken(userUUID, roles)) + .refreshToken(jwtTokenizer.createRefreshToken(userUUID, roles)) + .build(); + tokenRepository.save(TokenForRedis.builder() + .refreshToken(jwtPairDto.getRefreshToken()) + .refreshExpire(refreshExpire) + .userUUID(userUUID) + .build()); + return jwtPairDto; + } + + @Override + public String extendLoginState(String refreshToken) { + TokenForRedis tokenObject = tokenRepository.findById(refreshToken) + .orElseThrow(() -> new BabpoolJwtException(JwtExceptionErrorCode.REFRESH_TOKEN_NOT_FOUND, + "refresh token not found in redis, while extending login state.")); + + Claims claims = jwtTokenizer.parseRefreshToken(refreshToken); + String userUUID = claims.getSubject(); + + // TODO : Redis 에서 꺼낸 tokenObject 의 userUUID 와 request refresh token claims 의 userUUID 가 다른지 까지 검증해야 할까요? + if (!tokenObject.getUserUUID().equals(userUUID)) { + throw new BabpoolJwtException(JwtExceptionErrorCode.REFRESH_TOKEN_NOT_SAME_USER, + "user uuid in request refresh token and redis refresh token are not same, while extending login state."); + } + + return jwtTokenizer.createAccessToken(userUUID, claims.get("roles", List.class)); + } + + @Override + public void logout(String refreshTokenFromCookie) { + tokenRepository.findById(refreshTokenFromCookie) + .orElseThrow(() -> new BabpoolJwtException(JwtExceptionErrorCode.REFRESH_TOKEN_NOT_FOUND, + "refresh token not found in redis, while logout")); + tokenRepository.deleteById(refreshTokenFromCookie); + } +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/application/response/JwtPairDto.java b/src/main/java/com/swyp3/babpool/global/jwt/application/response/JwtPairDto.java new file mode 100644 index 00000000..52092799 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/application/response/JwtPairDto.java @@ -0,0 +1,24 @@ +package com.swyp3.babpool.global.jwt.application.response; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@ToString +@Getter +public class JwtPairDto { + + /** + * @apiNote + * accessToken : 사용자의 인증을 확인하는데 사용되는 토큰으로, 클라이언트의 Local Storage 에 저장한다. + * refreshToken : accessToken의 만료시간이 지나면 사용자의 인증을 연장하는데 사용되는 토큰으로, httpsOnly 속성을 가진 쿠키에 저장한다. + */ + public String accessToken; + public String refreshToken; + + @Builder + public JwtPairDto(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/exception/BabpoolJwtException.java b/src/main/java/com/swyp3/babpool/global/jwt/exception/BabpoolJwtException.java new file mode 100644 index 00000000..033f8917 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/exception/BabpoolJwtException.java @@ -0,0 +1,13 @@ +package com.swyp3.babpool.global.jwt.exception; + +import com.swyp3.babpool.global.jwt.exception.errorcode.JwtExceptionErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class BabpoolJwtException extends RuntimeException{ + + private final JwtExceptionErrorCode jwtExceptionErrorCode; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/exception/BadCredentialsException.java b/src/main/java/com/swyp3/babpool/global/jwt/exception/BadCredentialsException.java new file mode 100644 index 00000000..2cfcfdb8 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/exception/BadCredentialsException.java @@ -0,0 +1,13 @@ +package com.swyp3.babpool.global.jwt.exception; + +import com.swyp3.babpool.global.jwt.exception.errorcode.JwtExceptionErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class BadCredentialsException extends RuntimeException{ + + private final JwtExceptionErrorCode jwtExceptionErrorCode; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/exception/errorcode/JwtExceptionErrorCode.java b/src/main/java/com/swyp3/babpool/global/jwt/exception/errorcode/JwtExceptionErrorCode.java new file mode 100644 index 00000000..974fb46d --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/exception/errorcode/JwtExceptionErrorCode.java @@ -0,0 +1,23 @@ +package com.swyp3.babpool.global.jwt.exception.errorcode; + +import com.swyp3.babpool.global.common.exception.errorcode.CustomErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum JwtExceptionErrorCode implements CustomErrorCode { + + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "refresh token not found"), + REFRESH_TOKEN_NOT_SAME_USER(HttpStatus.UNAUTHORIZED, "refresh token not same user"), + + UNKNOWN_ERROR(HttpStatus.UNAUTHORIZED, "알 수 없는 에러"), + NOT_FOUND_TOKEN(HttpStatus.UNAUTHORIZED, "Headers에서 토큰 형식의 값을 찾을 수 없음"), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰"), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "기간이 만료된 토큰"), + UNSUPPORTED_TOKEN(HttpStatus.UNAUTHORIZED, "지원하지 않는 토큰"); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/global/jwt/exception/handler/JwtExceptionHandler.java b/src/main/java/com/swyp3/babpool/global/jwt/exception/handler/JwtExceptionHandler.java new file mode 100644 index 00000000..f0ed987c --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/jwt/exception/handler/JwtExceptionHandler.java @@ -0,0 +1,27 @@ +package com.swyp3.babpool.global.jwt.exception.handler; + +import com.swyp3.babpool.global.common.response.ApiErrorResponse; +import com.swyp3.babpool.global.jwt.exception.BabpoolJwtException; +import com.swyp3.babpool.global.jwt.exception.BadCredentialsException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class JwtExceptionHandler { + + @ExceptionHandler + protected ApiErrorResponse handleBabpoolJwtException(BabpoolJwtException exception){ + log.error("JwtException getJwtExceptionErrorCode() >> "+exception.getJwtExceptionErrorCode()); + log.error("JwtException getMessage() >> "+exception.getMessage()); + return ApiErrorResponse.of(exception.getJwtExceptionErrorCode()); + } + + @ExceptionHandler + protected ApiErrorResponse handleJwtBadCredentialsException(BadCredentialsException exception){ + log.error("BadCredentialsException getJwtExceptionErrorCode() >> "+exception.getJwtExceptionErrorCode()); + log.error("BadCredentialsException getMessage() >> "+exception.getMessage()); + return ApiErrorResponse.of(exception.getJwtExceptionErrorCode()); + } +} diff --git a/src/main/java/com/swyp3/babpool/global/uuid/application/UuidService.java b/src/main/java/com/swyp3/babpool/global/uuid/application/UuidService.java new file mode 100644 index 00000000..e7ec1486 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/uuid/application/UuidService.java @@ -0,0 +1,9 @@ +package com.swyp3.babpool.global.uuid.application; + +import java.util.UUID; + +public interface UuidService { + + UUID createUuid(Long userId); + UUID getUuidByUserId(Long userId); +} diff --git a/src/main/java/com/swyp3/babpool/global/uuid/application/UuidServiceV7.java b/src/main/java/com/swyp3/babpool/global/uuid/application/UuidServiceV7.java new file mode 100644 index 00000000..0d50b072 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/uuid/application/UuidServiceV7.java @@ -0,0 +1,46 @@ +package com.swyp3.babpool.global.uuid.application; + +import com.swyp3.babpool.global.uuid.dao.UserUuidRepository; +import com.swyp3.babpool.global.uuid.domain.UserUuid; +import com.swyp3.babpool.global.uuid.exception.UuidErrorCode; +import com.swyp3.babpool.global.uuid.exception.UuidException; +import com.swyp3.babpool.global.uuid.util.UuidResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Primary +@Service +@RequiredArgsConstructor +public class UuidServiceV7 implements UuidService{ + + private final UuidResolver uuidResolver; + private final UserUuidRepository uuidRepository; + + @Override + public UUID createUuid(Long userId) { + // UUID 생성 + UUID targetUuid = uuidResolver.generateUuid(); + // Parse UUID to Byte[] + byte[] targetBytes = uuidResolver.parseUuidToBytes(targetUuid); + // DB에 UUID 저장 + uuidRepository.save(UserUuid.builder() + .userId(userId) + .userUuid(targetBytes) + .build()); + // UUID 타입 반환 + return targetUuid; + } + + public UUID getUuidByUserId(Long userId) { + byte[] resultUuidBytes = uuidRepository.findByUserId(userId).orElseThrow( + () -> new UuidException(UuidErrorCode.NOT_FOUND_USER_UUID, + "Not found user uuid with user id, while UuidServiceV7.getUuidByUserId() request to UserUuidRepository")) + .getUserUuid(); + return uuidResolver.parseBytesToUuid(resultUuidBytes); + } + + +} diff --git a/src/main/java/com/swyp3/babpool/global/uuid/dao/UserUuidRepository.java b/src/main/java/com/swyp3/babpool/global/uuid/dao/UserUuidRepository.java new file mode 100644 index 00000000..e6c274e6 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/uuid/dao/UserUuidRepository.java @@ -0,0 +1,16 @@ +package com.swyp3.babpool.global.uuid.dao; + +import com.swyp3.babpool.global.uuid.domain.UserUuid; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Optional; + +@Mapper +public interface UserUuidRepository { + + Optional findByUserUuIdBytes(byte[] userUuid); + + void save(UserUuid userUuid); + + Optional findByUserId(Long userId); +} diff --git a/src/main/java/com/swyp3/babpool/global/uuid/domain/UserUuid.java b/src/main/java/com/swyp3/babpool/global/uuid/domain/UserUuid.java new file mode 100644 index 00000000..9456c378 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/uuid/domain/UserUuid.java @@ -0,0 +1,19 @@ +package com.swyp3.babpool.global.uuid.domain; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@ToString +@Getter +public class UserUuid { + + private Long userId; + private byte[] userUuid; + + @Builder + public UserUuid(Long userId, byte[] userUuid) { + this.userId = userId; + this.userUuid = userUuid; + } +} diff --git a/src/main/java/com/swyp3/babpool/global/uuid/exception/UuidErrorCode.java b/src/main/java/com/swyp3/babpool/global/uuid/exception/UuidErrorCode.java new file mode 100644 index 00000000..08b3edac --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/uuid/exception/UuidErrorCode.java @@ -0,0 +1,16 @@ +package com.swyp3.babpool.global.uuid.exception; + +import com.swyp3.babpool.global.common.exception.errorcode.CustomErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UuidErrorCode implements CustomErrorCode { + NOT_FOUND_USER_UUID(HttpStatus.NOT_FOUND, "Not found user uuid."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/global/uuid/exception/UuidException.java b/src/main/java/com/swyp3/babpool/global/uuid/exception/UuidException.java new file mode 100644 index 00000000..dc7bc045 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/uuid/exception/UuidException.java @@ -0,0 +1,12 @@ +package com.swyp3.babpool.global.uuid.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class UuidException extends RuntimeException{ + + private final UuidErrorCode errorCode; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/global/uuid/exception/UuidExceptionHandler.java b/src/main/java/com/swyp3/babpool/global/uuid/exception/UuidExceptionHandler.java new file mode 100644 index 00000000..b6a99d28 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/uuid/exception/UuidExceptionHandler.java @@ -0,0 +1,19 @@ +package com.swyp3.babpool.global.uuid.exception; + +import com.swyp3.babpool.global.common.response.ApiErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class UuidExceptionHandler { + + @ExceptionHandler + protected ApiErrorResponse handleUuidException(UuidException exception){ + log.error("UuidException getUuidErrorCode() >> "+exception.getErrorCode()); + log.error("UuidException getMessage() >> "+exception.getMessage()); + return ApiErrorResponse.of(exception.getErrorCode()); + } + +} diff --git a/src/main/java/com/swyp3/babpool/global/uuid/util/UuidResolver.java b/src/main/java/com/swyp3/babpool/global/uuid/util/UuidResolver.java new file mode 100644 index 00000000..68857653 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/global/uuid/util/UuidResolver.java @@ -0,0 +1,34 @@ +package com.swyp3.babpool.global.uuid.util; + +import com.fasterxml.uuid.Generators; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.ByteBuffer; +import java.util.UUID; + +@Slf4j +@Component +public class UuidResolver { + + public UUID generateUuid() { + UUID uuidV7 = Generators.timeBasedEpochGenerator().generate(); + log.info("UUID Version 7 created : {}", uuidV7); + return uuidV7; + } + + public byte[] parseUuidToBytes(UUID targetUuid) { + ByteBuffer bb = ByteBuffer.wrap(new byte[16]); + bb.putLong(targetUuid.getMostSignificantBits()); + bb.putLong(targetUuid.getLeastSignificantBits()); + return bb.array(); + } + + public UUID parseBytesToUuid(byte[] targetUuidBytes) { + ByteBuffer bb = ByteBuffer.wrap(targetUuidBytes); + long mostSignificantBits = bb.getLong(); + long leastSignificantBits = bb.getLong(); + return new UUID(mostSignificantBits, leastSignificantBits); + } + +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/AuthJwtParser.java b/src/main/java/com/swyp3/babpool/infra/auth/AuthJwtParser.java new file mode 100644 index 00000000..68b0b830 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/AuthJwtParser.java @@ -0,0 +1,48 @@ +package com.swyp3.babpool.infra.auth; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp3.babpool.infra.auth.exception.AuthException; +import com.swyp3.babpool.infra.auth.exception.errorcode.AuthExceptionErrorCode; +import io.jsonwebtoken.*; +import org.springframework.stereotype.Component; + +import java.security.PublicKey; +import io.jsonwebtoken.security.SignatureException; +import java.util.Base64; +import java.util.Map; + +@Component +public class AuthJwtParser { + private static final String IDENTITY_TOKEN_SPLITER = "\\."; + private static final int HEADER_INDEX = 0; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public Map parseHeaders(String identityToken){ + try { + String encodedHeader = identityToken.split(IDENTITY_TOKEN_SPLITER)[HEADER_INDEX]; + String decodedHeader = new String(Base64.getDecoder().decode(encodedHeader)); + return objectMapper.readValue(decodedHeader, Map.class); + } catch(JsonProcessingException | ArrayIndexOutOfBoundsException e) { + throw new AuthException(AuthExceptionErrorCode.AUTH_UNSUPPORTED_ID_TOKEN_TYPE, + "Identity Token 헤더 parse 중 문제가 발생했습니다."); + } + } + + public Claims parsePublicKeyAndGetClaims(String idToken, PublicKey publicKey) { + try { + return Jwts.parserBuilder() + .setSigningKey(publicKey) + .build() + .parseClaimsJws(idToken) + .getBody(); + }catch(ExpiredJwtException e){ + throw new AuthException(AuthExceptionErrorCode.AUTH_TOKEN_EXPIRED, + "유효기간이 만료된 Identity Token 입니다."); + }catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e){ + throw new AuthException(AuthExceptionErrorCode.AUTH_MALFORMED_TOKEN, + "유효하지 않은 Identity Token 입니다."); + } + } +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/AuthPlatform.java b/src/main/java/com/swyp3/babpool/infra/auth/AuthPlatform.java new file mode 100644 index 00000000..9c3884da --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/AuthPlatform.java @@ -0,0 +1,11 @@ +package com.swyp3.babpool.infra.auth; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import lombok.Getter; + +@Getter +@JsonDeserialize(using = PlatformDeserializer.class) +public enum AuthPlatform { + KAKAO, + GOOGLE +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/BabpoolPublicKey.java b/src/main/java/com/swyp3/babpool/infra/auth/BabpoolPublicKey.java new file mode 100644 index 00000000..9d984945 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/BabpoolPublicKey.java @@ -0,0 +1,22 @@ +package com.swyp3.babpool.infra.auth; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access= AccessLevel.PROTECTED) +public class BabpoolPublicKey { + //The key type parameter setting. You must set to "RSA". + private String kty; + //A 10-character identifier key, obtained from your developer account + private String kid; + //The intended use for the public key + private String use; + //The encryption algorithm used to encrypt the token + private String alg; + //The modulus value for the RSA public key + private String n; + //The exponent value for the RSA public key + private String e; +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/PlatformDeserializer.java b/src/main/java/com/swyp3/babpool/infra/auth/PlatformDeserializer.java new file mode 100644 index 00000000..9d2f69a3 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/PlatformDeserializer.java @@ -0,0 +1,14 @@ +package com.swyp3.babpool.infra.auth; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; + +public class PlatformDeserializer extends JsonDeserializer { + @Override + public AuthPlatform deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + return AuthPlatform.valueOf(jsonParser.getText().toUpperCase()); + } +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/PublicKeyGenerator.java b/src/main/java/com/swyp3/babpool/infra/auth/PublicKeyGenerator.java new file mode 100644 index 00000000..ba20e36e --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/PublicKeyGenerator.java @@ -0,0 +1,45 @@ +package com.swyp3.babpool.infra.auth; + +import com.swyp3.babpool.infra.auth.exception.AuthException; +import com.swyp3.babpool.infra.auth.exception.errorcode.AuthExceptionErrorCode; +import com.swyp3.babpool.infra.auth.kakao.KakaoPublicKeys; +import org.springframework.stereotype.Component; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Map; + +@Component +public class PublicKeyGenerator { + private static final String HEADER_SIGN_ALGORITHM = "alg"; + private static final String HEADER_KEY_ID = "kid"; + private static final int POSITIVE_SIGN_NUMBER = 1; + + public PublicKey generateKakaoPublicKey(Map headers, KakaoPublicKeys kakaoPublicKeys){ + BabpoolPublicKey kakaoPublicKey = kakaoPublicKeys.getMatchesKey(headers.get(HEADER_KEY_ID)); + return generatePublicKeyWithPublicKey(kakaoPublicKey); + } + + private PublicKey generatePublicKeyWithPublicKey(BabpoolPublicKey publicKey) { + byte[] nBytes = Base64.getUrlDecoder().decode(publicKey.getN()); + byte[] eBytes = Base64.getUrlDecoder().decode(publicKey.getE()); + + BigInteger n = new BigInteger(POSITIVE_SIGN_NUMBER, nBytes); + BigInteger e = new BigInteger(POSITIVE_SIGN_NUMBER, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + + try{ + KeyFactory keyFactory = KeyFactory.getInstance(publicKey.getKty()); + return keyFactory.generatePublic(publicKeySpec); + }catch(NoSuchAlgorithmException | InvalidKeySpecException exception){ + throw new AuthException(AuthExceptionErrorCode.AUTH_PUBLIC_KEY_ERROR + ,"OAuth 로그인 중 public key 생성에 문제가 발생했습니다."); + } + } +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/controller/AuthController.java b/src/main/java/com/swyp3/babpool/infra/auth/controller/AuthController.java new file mode 100644 index 00000000..1310d38f --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/controller/AuthController.java @@ -0,0 +1,38 @@ +package com.swyp3.babpool.infra.auth.controller; + +import com.swyp3.babpool.global.common.response.ApiResponse; +import com.swyp3.babpool.global.common.response.ApiResponseWithCookie; +import com.swyp3.babpool.infra.auth.request.LoginRequestDTO; +import com.swyp3.babpool.infra.auth.response.LoginResponseDTO; +import com.swyp3.babpool.infra.auth.response.LoginResponseWithRefreshToken; +import com.swyp3.babpool.infra.auth.service.AuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/user") +public class AuthController { + private final AuthService authService; + + @PostMapping("/sign/in") + public ApiResponseWithCookie login(@RequestBody @Valid LoginRequestDTO loginRequest){ + LoginResponseWithRefreshToken loginResponseData = authService.kakaoLogin(loginRequest); + Boolean isRegistered = loginResponseData.getLoginResponseDTO().getIsRegistered(); + + //로그인 성공한 경우 + if(isRegistered) + return ApiResponseWithCookie.ofRefreshToken(HttpStatus.OK,"로그인에 성공하였습니다", + loginResponseData.getLoginResponseDTO(), loginResponseData.getRefreshToken()); + //추가정보 입력이 필요한 경우 + return ApiResponseWithCookie.ofRefreshToken(HttpStatus.UNAUTHORIZED,"추가정보 입력이 필요한 사용자입니다", + loginResponseData.getLoginResponseDTO(), loginResponseData.getRefreshToken()); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp3/babpool/infra/auth/dao/AuthRepository.java b/src/main/java/com/swyp3/babpool/infra/auth/dao/AuthRepository.java new file mode 100644 index 00000000..f3820127 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/dao/AuthRepository.java @@ -0,0 +1,11 @@ +package com.swyp3.babpool.infra.auth.dao; + +import com.swyp3.babpool.infra.auth.AuthPlatform; +import com.swyp3.babpool.infra.auth.domain.Auth; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface AuthRepository { + void save(Auth oauth); +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/domain/Auth.java b/src/main/java/com/swyp3/babpool/infra/auth/domain/Auth.java new file mode 100644 index 00000000..a58bc143 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/domain/Auth.java @@ -0,0 +1,30 @@ +package com.swyp3.babpool.infra.auth.domain; + +import com.swyp3.babpool.domain.user.domain.User; +import com.swyp3.babpool.domain.user.domain.UserRole; +import com.swyp3.babpool.domain.user.domain.UserStatus; +import com.swyp3.babpool.infra.auth.AuthPlatform; +import com.swyp3.babpool.infra.auth.response.AuthMemberResponse; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +public class Auth { + private Long oauthId; + private Long userId; + private AuthPlatform oauthPlatformName; + private String oauthPlatformId; + + public static Auth createAuth(Long userId, AuthPlatform platformName, String platformId) { + Auth auth = new Auth(); + + auth.userId = userId; + auth.oauthPlatformId = platformId; + auth.oauthPlatformName = platformName; + + return auth; + } +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/exception/AuthException.java b/src/main/java/com/swyp3/babpool/infra/auth/exception/AuthException.java new file mode 100644 index 00000000..31239e72 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/exception/AuthException.java @@ -0,0 +1,12 @@ +package com.swyp3.babpool.infra.auth.exception; + +import com.swyp3.babpool.infra.auth.exception.errorcode.AuthExceptionErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class AuthException extends RuntimeException{ + private final AuthExceptionErrorCode authExceptionErrorCode; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/exception/errorcode/AuthExceptionErrorCode.java b/src/main/java/com/swyp3/babpool/infra/auth/exception/errorcode/AuthExceptionErrorCode.java new file mode 100644 index 00000000..6c58ef84 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/exception/errorcode/AuthExceptionErrorCode.java @@ -0,0 +1,20 @@ +package com.swyp3.babpool.infra.auth.exception.errorcode; + +import com.swyp3.babpool.global.common.exception.errorcode.CustomErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthExceptionErrorCode implements CustomErrorCode { + AUTH_JWT_ERROR(HttpStatus.UNAUTHORIZED,"AUTH JWT에서 오류가 발생했습니다."), + AUTH_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST,"유효하지 않은 토큰입니다."), + AUTH_MALFORMED_TOKEN(HttpStatus.UNAUTHORIZED,"올바르지 않은 토큰입니다."), + AUTH_UNSUPPORTED_ID_TOKEN_TYPE(HttpStatus.UNAUTHORIZED,"OAuth Identity Token의 형식이 올바르지 않습니다."), + AUTH_PUBLIC_KEY_ERROR(HttpStatus.UNAUTHORIZED,"OAuth Public Key 생성 중 오류가 발생했습니다."), + AUTH_ERROR_CONNECT_WITH_KAKAO(HttpStatus.INTERNAL_SERVER_ERROR,"Kakao로부터 id Token을 응답받는 과정 중 오류가 발생했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/exception/handler/AuthExceptionHandler.java b/src/main/java/com/swyp3/babpool/infra/auth/exception/handler/AuthExceptionHandler.java new file mode 100644 index 00000000..ddd1c9ee --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/exception/handler/AuthExceptionHandler.java @@ -0,0 +1,18 @@ +package com.swyp3.babpool.infra.auth.exception.handler; + +import com.swyp3.babpool.global.common.response.ApiErrorResponse; +import com.swyp3.babpool.infra.auth.exception.AuthException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class AuthExceptionHandler { + @ExceptionHandler + protected ApiErrorResponse handleAuthException(AuthException exception){ + log.error("AuthException getAuthExceptionErrorCode() >> "+exception.getAuthExceptionErrorCode()); + log.error("AuthException getMessage() >> "+exception.getMessage()); + return ApiErrorResponse.of(exception.getAuthExceptionErrorCode()); + } +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoClient.java b/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoClient.java new file mode 100644 index 00000000..d31317c4 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoClient.java @@ -0,0 +1,10 @@ +package com.swyp3.babpool.infra.auth.kakao; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "kakao-user-client", url = "https://kauth.kakao.com") +public interface KakaoClient { + @GetMapping("/.well-known/jwks.json") + KakaoPublicKeys getKakaoOIDCOpenKeys(); +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoMemberProvider.java b/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoMemberProvider.java new file mode 100644 index 00000000..5fb32860 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoMemberProvider.java @@ -0,0 +1,45 @@ +package com.swyp3.babpool.infra.auth.kakao; + +import com.swyp3.babpool.infra.auth.AuthJwtParser; +import com.swyp3.babpool.infra.auth.PublicKeyGenerator; +import com.swyp3.babpool.infra.auth.exception.AuthException; +import com.swyp3.babpool.infra.auth.exception.errorcode.AuthExceptionErrorCode; +import com.swyp3.babpool.infra.auth.response.AuthMemberResponse; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.PublicKey; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class KakaoMemberProvider { + private final AuthJwtParser authJwtParser; + private final PublicKeyGenerator publicKeyGenerator; + private final KakaoClient kakaoClient; + @Value("${property.oauth.kakao.iss}") + private String iss; + @Value("${property.oauth.kakao.client-id}") + private String clientId; + + public AuthMemberResponse getKakaoPlatformMember(String identityToken){ + Map headers = authJwtParser.parseHeaders(identityToken); + KakaoPublicKeys kakaoPublicKeys = kakaoClient.getKakaoOIDCOpenKeys(); + PublicKey publicKey = publicKeyGenerator.generateKakaoPublicKey(headers, kakaoPublicKeys); + + Claims claims = authJwtParser.parsePublicKeyAndGetClaims(identityToken, publicKey); + validateClaims(claims); + + return new AuthMemberResponse(claims.getSubject(),claims.get("nickname",String.class),claims.get("picture",String.class),claims.get("email",String.class)); + } + + private void validateClaims(Claims claims) { + if(!claims.getIssuer().contains(iss)&&claims.getAudience().equals(clientId)){ + throw new AuthException(AuthExceptionErrorCode.AUTH_JWT_ERROR, + "OAuth Claim 값이 올바르지 않습니다."); + } + } + +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoPublicKeys.java b/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoPublicKeys.java new file mode 100644 index 00000000..5afea5cb --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoPublicKeys.java @@ -0,0 +1,24 @@ +package com.swyp3.babpool.infra.auth.kakao; + +import com.swyp3.babpool.infra.auth.BabpoolPublicKey; +import com.swyp3.babpool.infra.auth.domain.Auth; +import com.swyp3.babpool.infra.auth.exception.AuthException; +import com.swyp3.babpool.infra.auth.exception.errorcode.AuthExceptionErrorCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class KakaoPublicKeys { + private List keys; + + public BabpoolPublicKey getMatchesKey(String kid) { + return this.keys.stream() + .filter(key -> key.getKid().equals(kid)) + .findFirst() + .orElseThrow(() -> new AuthException(AuthExceptionErrorCode.AUTH_JWT_ERROR, + "공개키 리스트 중 Identity Token을 디코딩할 공개키를 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoTokenProvider.java b/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoTokenProvider.java new file mode 100644 index 00000000..47fe9f1a --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/kakao/KakaoTokenProvider.java @@ -0,0 +1,89 @@ +package com.swyp3.babpool.infra.auth.kakao; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.swyp3.babpool.infra.auth.exception.AuthException; +import com.swyp3.babpool.infra.auth.exception.errorcode.AuthExceptionErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KakaoTokenProvider { + private String reqURL = "https://kauth.kakao.com/oauth/token"; + @Value("${property.oauth.kakao.client-id}") + private String clientId; + @Value("${property.oauth.kakao.redirect-uri}") + private String redirectUri; + @Value("${property.oauth.kakao.client-secret}") + private String clientSecret; + + public String getIdTokenFromKakao(String code) { + String idToken; + + try { + URL url = new URL(reqURL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + + BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream())); + StringBuilder sb = new StringBuilder(); + + sb.append("grant_type=authorization_code"); + sb.append("&client_id="+clientId); + sb.append("&redirect_uri="+redirectUri); + sb.append("&client_secret="+clientSecret); + sb.append("&code=" + code); + + bw.write(sb.toString()); + bw.flush(); + + BufferedReader br; + + if (conn.getResponseCode() >= 400) { + br = new BufferedReader(new InputStreamReader(conn.getErrorStream())); + } else { + br = new BufferedReader(new InputStreamReader(conn.getInputStream())); + } + + String line; + StringBuilder responseSb = new StringBuilder(); + + while ((line = br.readLine()) != null) { + responseSb.append(line); + } + + String result = responseSb.toString(); + JsonParser parser = new JsonParser(); + JsonElement element = parser.parse(result); + + + // 에러 응답이면 에러 메시지를 가져옴 + if (conn.getResponseCode() >= 400) { + String errorMessage = element.getAsJsonObject().get("error_description").getAsString(); + throw new AuthException(AuthExceptionErrorCode.AUTH_ERROR_CONNECT_WITH_KAKAO, + errorMessage); + } + // 정상 응답이면 id Token을 가져옴 + else{ + idToken = element.getAsJsonObject().get("id_token").getAsString(); + log.info("카카오로부터 Token 성공적으로 조회"); + br.close(); + + return idToken; + } + } catch (IOException e) { + e.printStackTrace(); + throw new AuthException(AuthExceptionErrorCode.AUTH_ERROR_CONNECT_WITH_KAKAO, + e.getMessage().toString()); + } + } +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/request/LoginRequestDTO.java b/src/main/java/com/swyp3/babpool/infra/auth/request/LoginRequestDTO.java new file mode 100644 index 00000000..c133b916 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/request/LoginRequestDTO.java @@ -0,0 +1,13 @@ +package com.swyp3.babpool.infra.auth.request; + +import com.swyp3.babpool.infra.auth.AuthPlatform; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class LoginRequestDTO { + @NotNull(message = "authPlatform 값은 필수입니다.") + private AuthPlatform authPlatform; + @NotNull(message = "code 값은 필수입니다.") + private String code; +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/response/AuthMemberResponse.java b/src/main/java/com/swyp3/babpool/infra/auth/response/AuthMemberResponse.java new file mode 100644 index 00000000..a080b4c2 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/response/AuthMemberResponse.java @@ -0,0 +1,19 @@ +package com.swyp3.babpool.infra.auth.response; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class AuthMemberResponse { + @NotNull(message="platformId는 필수입니다.") + private String platformId; + @NotNull(message="프로필 이름은 필수입니다.") + private String nickname; + @NotNull(message="프로필 이미지는 필수입니다.") + private String profile_image; + @NotNull(message="이메일은 필수입니다.") + private String email; + +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/response/LoginResponseDTO.java b/src/main/java/com/swyp3/babpool/infra/auth/response/LoginResponseDTO.java new file mode 100644 index 00000000..e1150b19 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/response/LoginResponseDTO.java @@ -0,0 +1,12 @@ +package com.swyp3.babpool.infra.auth.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class LoginResponseDTO { + private String userUuid; + private String accessToken; + private Boolean isRegistered; +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/response/LoginResponseWithRefreshToken.java b/src/main/java/com/swyp3/babpool/infra/auth/response/LoginResponseWithRefreshToken.java new file mode 100644 index 00000000..09813a7d --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/response/LoginResponseWithRefreshToken.java @@ -0,0 +1,12 @@ +package com.swyp3.babpool.infra.auth.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@AllArgsConstructor +public class LoginResponseWithRefreshToken { + LoginResponseDTO loginResponseDTO; + String refreshToken; +} diff --git a/src/main/java/com/swyp3/babpool/infra/auth/service/AuthService.java b/src/main/java/com/swyp3/babpool/infra/auth/service/AuthService.java new file mode 100644 index 00000000..ccfe7a3d --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/auth/service/AuthService.java @@ -0,0 +1,110 @@ +package com.swyp3.babpool.infra.auth.service; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.swyp3.babpool.domain.user.dao.UserRepository; +import com.swyp3.babpool.domain.user.domain.User; +import com.swyp3.babpool.domain.user.domain.UserRole; +import com.swyp3.babpool.global.jwt.application.JwtService; +import com.swyp3.babpool.global.jwt.application.response.JwtPairDto; +import com.swyp3.babpool.global.uuid.application.UuidService; +import com.swyp3.babpool.infra.auth.AuthPlatform; +import com.swyp3.babpool.infra.auth.domain.Auth; +import com.swyp3.babpool.infra.auth.exception.AuthException; +import com.swyp3.babpool.infra.auth.exception.errorcode.AuthExceptionErrorCode; +import com.swyp3.babpool.infra.auth.kakao.KakaoMemberProvider; +import com.swyp3.babpool.infra.auth.dao.AuthRepository; +import com.swyp3.babpool.infra.auth.kakao.KakaoTokenProvider; +import com.swyp3.babpool.infra.auth.response.AuthMemberResponse; +import com.swyp3.babpool.infra.auth.request.LoginRequestDTO; +import com.swyp3.babpool.infra.auth.response.LoginResponseDTO; +import com.swyp3.babpool.infra.auth.response.LoginResponseWithRefreshToken; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.UUID; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class AuthService { + private final KakaoMemberProvider kakaoMemberProvider; + private final KakaoTokenProvider kakaoTokenProvider; + private final UserRepository userRepository; + private final AuthRepository authRepository; + private final JwtService jwtService; + private final UuidService uuidService; + + public LoginResponseWithRefreshToken kakaoLogin(LoginRequestDTO loginRequest) { + String idToken = kakaoTokenProvider.getIdTokenFromKakao(loginRequest.getCode()); + AuthMemberResponse kakaoPlatformMember = kakaoMemberProvider.getKakaoPlatformMember(idToken); + return generateLoginResponse(AuthPlatform.KAKAO,kakaoPlatformMember); + } + + private LoginResponseWithRefreshToken generateLoginResponse(AuthPlatform authPlatform, AuthMemberResponse authMemberResponse) { + Long findUserId = userRepository.findUserIdByPlatformAndPlatformId(authPlatform, authMemberResponse.getPlatformId()); + + //회원테이블에 id Token 정보가 저장되어있는 경우 + if(findUserId!=null) { + User findUser = userRepository.findById(findUserId); + if(isNeedMoreInfo(findUser)) + return getLoginResponseNeedSignUp(findUser);// (회원가입이 완료된 경우) 사용자 추가정보 입력 필요 + return getLoginResponse(findUser); + } + + //회원테이블에 아무 정보도 없는 경우 + User createdUser = createUser(authPlatform, authMemberResponse); + return getLoginResponseNeedSignUp(createdUser); + } + + private LoginResponseWithRefreshToken getLoginResponseNeedSignUp(User user) { + String userUuid = String.valueOf(uuidService.getUuidByUserId(user.getUserId())); + LoginResponseDTO loginResponseDTO = new LoginResponseDTO(userUuid, null, false); + return new LoginResponseWithRefreshToken(loginResponseDTO,null); + } + + private boolean isNeedMoreInfo(User targetUser){ + if(targetUser.getUserGrade().equals("none")){ + return true; + } + return false; + } + + private LoginResponseWithRefreshToken getLoginResponse(User user) { + String userUuid = String.valueOf(uuidService.getUuidByUserId(user.getUserId())); + JwtPairDto jwtPair = jwtService.createJwtPair(userUuid, new ArrayList(Arrays.asList(UserRole.USER))); + + String accessToken = jwtPair.getAccessToken(); + String refreshToken = jwtPair.getRefreshToken(); + + LoginResponseDTO loginResponseDTO = new LoginResponseDTO(userUuid, accessToken,true); + + return new LoginResponseWithRefreshToken(loginResponseDTO,refreshToken); + } + + private User createUser(AuthPlatform authPlatform, AuthMemberResponse authMemberResponse) { + User user = User.builder() + .email(authMemberResponse.getEmail()) + .nickName(authMemberResponse.getNickname()) + .build(); + userRepository.save(user); + Long savedId = user.getUserId(); + + Auth auth = Auth.createAuth(savedId, authPlatform, authMemberResponse.getPlatformId()); + authRepository.save(auth); + + uuidService.createUuid(savedId); + return user; + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp3/babpool/infra/health/api/HealthCheckApi.java b/src/main/java/com/swyp3/babpool/infra/health/api/HealthCheckApi.java index b0264076..624bdfa4 100644 --- a/src/main/java/com/swyp3/babpool/infra/health/api/HealthCheckApi.java +++ b/src/main/java/com/swyp3/babpool/infra/health/api/HealthCheckApi.java @@ -1,19 +1,75 @@ package com.swyp3.babpool.infra.health.api; +import com.swyp3.babpool.domain.user.domain.UserRole; +import com.swyp3.babpool.global.common.response.ApiResponseWithCookie; +import com.swyp3.babpool.global.jwt.application.JwtService; +import com.swyp3.babpool.global.jwt.application.response.JwtPairDto; +import com.swyp3.babpool.global.uuid.application.UuidService; import com.swyp3.babpool.infra.health.application.HealthCheckService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import java.util.List; +import java.util.Map; + +@Slf4j @RestController @RequiredArgsConstructor public class HealthCheckApi { private final HealthCheckService healthCheckService; + private final JwtService jwtService; + private final UuidService uuidService; @GetMapping("/api/test/connection") public ResponseEntity testConnection() { return ResponseEntity.ok(healthCheckService.testConnection()); } + + /** + * Test for JWT token validation - require valid token + * @param userId : user id 가 필요한 API 인 경우, request attribute 으로 부터 userId를 가져와서 사용. + */ + @GetMapping("/api/test/jwt/require") + public ResponseEntity testJwtRequire(@RequestAttribute(value = "userId", required = false) Long userId) { + log.info("userId from request header : {}", userId); + return ResponseEntity.ok("success"); + } + + /** + * Test for JWT token validation - permitted + * @apiNote : token 검증이 필요 없는 예외 API 인 경우, /global/config/WebMvcInterceptorJwtConfig 에서 URL 추가. + */ + @GetMapping("/api/test/jwt/permitted") + public ResponseEntity testJwtPermitted() { + return ResponseEntity.ok("success"); + } + + /** + * Test for generating JWT tokens + * @apiNote : JWT token 생성 테스트 + */ + @PostMapping("/api/test/jwt/tokens") + public ResponseEntity testGenerateJwtTokens(@RequestBody Map requestBody) { + return ResponseEntity.ok(jwtService.createJwtPair(requestBody.get("userUuid"), List.of(UserRole.USER))); + } + + /** + * Test for generating UUID + * @apiNote : UUID 생성 테스트 + * @return UUID string type + */ + @PostMapping("/api/test/uuid") + public ResponseEntity testGenerateUuid(@RequestBody Map requestBody) { + return ResponseEntity.ok(String.valueOf(uuidService.createUuid(requestBody.get("userId")))); + } + + @GetMapping("/api/test/cookie") + public ApiResponseWithCookie testCookie() { + return ApiResponseWithCookie.ofRefreshToken(HttpStatus.OK, "success", "data", "refreshToken1234"); + } + } diff --git a/src/main/java/com/swyp3/babpool/infra/health/api/HealthCheckS3Api.java b/src/main/java/com/swyp3/babpool/infra/health/api/HealthCheckS3Api.java new file mode 100644 index 00000000..9f70ebe4 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/health/api/HealthCheckS3Api.java @@ -0,0 +1,31 @@ +package com.swyp3.babpool.infra.health.api; + +import com.swyp3.babpool.global.common.response.ApiResponse; +import com.swyp3.babpool.infra.s3.application.AwsS3Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +public class HealthCheckS3Api { + + private final AwsS3Provider awsS3Provider; + + @PostMapping("/api/test/image/upload") + public ApiResponse testImageUpload(@RequestPart("profileImageFile")MultipartFile multipartFile){ + return ApiResponse.ok(awsS3Provider.uploadImage(multipartFile)); + } + + @PostMapping("/api/test/image/delete") + public ApiResponse testImageDelete(@RequestBody Map param){ + awsS3Provider.deleteImage(param.get("imageUrl")); + return ApiResponse.ok("S3 이미지 삭제에 성공했습니다."); + } + +} diff --git a/src/main/java/com/swyp3/babpool/infra/redis/EmbeddedLocalRedisConfig.java b/src/main/java/com/swyp3/babpool/infra/redis/EmbeddedLocalRedisConfig.java new file mode 100644 index 00000000..833e3e8d --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/redis/EmbeddedLocalRedisConfig.java @@ -0,0 +1,101 @@ +package com.swyp3.babpool.infra.redis; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.util.StringUtils; +import redis.embedded.RedisServer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; + +@Slf4j +@Profile("!production") +@Configuration +public class EmbeddedLocalRedisConfig { + + @Value("${spring.data.redis.port}") + private int redisPort; + + private RedisServer redisServer; + + @PostConstruct + public void redisServer() throws IOException { + int port = isRedisRunning() ? findAvailablePort() : redisPort; + redisServer = RedisServer.builder() + .port(port) + .setting("maxmemory 128M") + .build(); + redisServer.start(); + } + + @PreDestroy + public void stopRedis() { + if (redisServer != null) { + redisServer.stop(); + } + } + + + /** + * Embedded Redis가 현재 실행중인지 확인 + */ + private boolean isRedisRunning() throws IOException { + return isRunning(executeGrepProcessCommand(redisPort)); + } + + /** + * 현재 PC/서버에서 사용가능한 포트 조회 + */ + public int findAvailablePort() throws IOException { + + for (int port = 10000; port <= 65535; port++) { + Process process = executeGrepProcessCommand(port); + if (!isRunning(process)) { + return port; + } + } + + throw new IllegalArgumentException("Not Found Available port: 10000 ~ 65535"); + } + + /** + * 해당 port를 사용중인 프로세스 확인하는 sh 실행 + */ + private Process executeGrepProcessCommand(int port) throws IOException { + String OS = System.getProperty("os.name").toLowerCase(); + if (OS.contains("win")) { + log.info("OS is " + OS + " " + port); + String command = String.format("netstat -nao | find \"LISTEN\" | find \"%d\"", port); + String[] shell = {"cmd.exe", "/y", "/c", command}; + return Runtime.getRuntime().exec(shell); + } + String command = String.format("netstat -nat | grep LISTEN|grep %d", port); + String[] shell = {"/bin/sh", "-c", command}; + return Runtime.getRuntime().exec(shell); + } + + /** + * 해당 Process가 현재 실행중인지 확인 + */ + private boolean isRunning(Process process) { + String line; + StringBuilder pidInfo = new StringBuilder(); + + try (BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + + while ((line = input.readLine()) != null) { + pidInfo.append(line); + } + + } catch (Exception e) { + } + + return StringUtils.hasText(pidInfo.toString()); + } + +} diff --git a/src/main/java/com/swyp3/babpool/infra/redis/RedisRepositoryConfig.java b/src/main/java/com/swyp3/babpool/infra/redis/RedisRepositoryConfig.java new file mode 100644 index 00000000..295662e9 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/redis/RedisRepositoryConfig.java @@ -0,0 +1,58 @@ +package com.swyp3.babpool.infra.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; + +import java.util.Arrays; + +//@Profile("!production") +@Configuration +@EnableRedisRepositories +@RequiredArgsConstructor +public class RedisRepositoryConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + /** + * @deprecated Redis password not necessary. AWS ElastiCache for Redis support through EC2 connection only. + */ +// @Value(value = "${spring.data.redis.password}") +// private String redisPassword; + + private final Environment environment; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(); + redisStandaloneConfiguration.setHostName(redisHost); + redisStandaloneConfiguration.setPort(redisPort); + // set redis password when active profile is production +// Arrays.stream(environment.getActiveProfiles()).forEach(profile -> { +// if (profile.equals("production")) { +// redisStandaloneConfiguration.setPassword(redisPassword); +// } +// }); + return new LettuceConnectionFactory(redisStandaloneConfiguration); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } +} diff --git a/src/main/java/com/swyp3/babpool/infra/redis/dao/TokenRedisRepository.java b/src/main/java/com/swyp3/babpool/infra/redis/dao/TokenRedisRepository.java new file mode 100644 index 00000000..e8105db8 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/redis/dao/TokenRedisRepository.java @@ -0,0 +1,7 @@ +package com.swyp3.babpool.infra.redis.dao; + +import com.swyp3.babpool.infra.redis.domain.TokenForRedis; +import org.springframework.data.repository.CrudRepository; + +public interface TokenRedisRepository extends CrudRepository { +} diff --git a/src/main/java/com/swyp3/babpool/infra/redis/domain/TokenForRedis.java b/src/main/java/com/swyp3/babpool/infra/redis/domain/TokenForRedis.java new file mode 100644 index 00000000..d617fb9b --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/redis/domain/TokenForRedis.java @@ -0,0 +1,31 @@ +package com.swyp3.babpool.infra.redis.domain; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import java.util.concurrent.TimeUnit; + +@ToString +@Getter +@RedisHash("token") +public class TokenForRedis { + + @Id + String refreshToken; + + String userUUID; + + @TimeToLive(unit = TimeUnit.DAYS) + Integer refreshExpire; + + @Builder + public TokenForRedis(String userUUID, String refreshToken, Integer refreshExpire) { + this.userUUID = userUUID; + this.refreshToken = refreshToken; + this.refreshExpire = refreshExpire; + } +} diff --git a/src/main/java/com/swyp3/babpool/infra/s3/application/AwsS3Provider.java b/src/main/java/com/swyp3/babpool/infra/s3/application/AwsS3Provider.java new file mode 100644 index 00000000..15718e7d --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/s3/application/AwsS3Provider.java @@ -0,0 +1,95 @@ +package com.swyp3.babpool.infra.s3.application; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.DeleteObjectsRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.fasterxml.uuid.Generators; +import com.swyp3.babpool.infra.s3.exception.AwsS3ErrorCode; +import com.swyp3.babpool.infra.s3.exception.AwsS3Exception; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsS3Provider { + private static final String S3_BUCKET_DIRECTORY_NAME = "static"; + private static final Map S3_ALLOWED_IMAGE_FILE_TYPES = Map.of( + "image/jpeg", true, + "image/png", true, + "image/gif", true + ); + private static final Integer S3_MAX_IMAGE_FILE_SIZE = 5_000_000; // 5MB + private final AmazonS3 amazonS3Client; + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + + public String uploadImage(@NotNull MultipartFile multipartFile) { + validateImageFileType(multipartFile.getContentType()); + validateImageFileSize(multipartFile.getSize()); + + String fileName = generateFileName(multipartFile.getOriginalFilename()); + uploadToS3Bucket(multipartFile, fileName); + + String imageUrl = amazonS3Client.getUrl(bucket, fileName).toString(); + log.info("S3 파일 업로드에 성공. URL: {}", imageUrl); + return imageUrl; + } + + private void uploadToS3Bucket(MultipartFile multipartFile, String fileName) { + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentType(multipartFile.getContentType()); + objectMetadata.setContentLength(multipartFile.getSize()); + + try (InputStream inputStream = multipartFile.getInputStream()) { + amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + } catch (IOException e) { + log.error("S3 파일 업로드에 실패했습니다. {}", e.getMessage()); + throw new AwsS3Exception(AwsS3ErrorCode.AWS_S3_IMAGE_UPLOAD_FAIL, + "Aws s3 image upload fail, in AwsS3Uploader.uploadImage() method."); + } + } + + private String generateFileName(String originalFileName) { + return S3_BUCKET_DIRECTORY_NAME + "/" + Generators.timeBasedEpochGenerator().generate() + "." + originalFileName; + } + + private void validateImageFileType(String targetContentType) { + if (!S3_ALLOWED_IMAGE_FILE_TYPES.containsKey(targetContentType)) { + throw new AwsS3Exception(AwsS3ErrorCode.AWS_S3_INVALID_FILE_TYPE, + "Invalid file type. Only JPEG and PNG are supported."); + } + } + + private void validateImageFileSize(Long targetSize) { + if (targetSize > S3_MAX_IMAGE_FILE_SIZE) { // 5MB + throw new AwsS3Exception(AwsS3ErrorCode.AWS_S3_FILE_TOO_LARGE, + "File size is too large. Maximum allowed size is 5MB."); + } + } + + public boolean deleteImage(String imageUrlFromProfileRepository) { + String fileName = S3_BUCKET_DIRECTORY_NAME + imageUrlFromProfileRepository.substring(imageUrlFromProfileRepository.lastIndexOf("/")); + try { + amazonS3Client.deleteObject(bucket, fileName); + } catch (Exception e) { + log.error("S3 파일 삭제에 실패했습니다. {}", e.getMessage()); + throw new AwsS3Exception(AwsS3ErrorCode.AWS_S3_IMAGE_DELETE_FAIL, + "Aws s3 image delete fail, in AwsS3Uploader.deleteImage() method."); + } + return true; + } + +} diff --git a/src/main/java/com/swyp3/babpool/infra/s3/config/S3Config.java b/src/main/java/com/swyp3/babpool/infra/s3/config/S3Config.java new file mode 100644 index 00000000..94500c9b --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/s3/config/S3Config.java @@ -0,0 +1,32 @@ +package com.swyp3.babpool.infra.s3.config; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3Client() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } +} diff --git a/src/main/java/com/swyp3/babpool/infra/s3/exception/AwsS3ErrorCode.java b/src/main/java/com/swyp3/babpool/infra/s3/exception/AwsS3ErrorCode.java new file mode 100644 index 00000000..eb90578b --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/s3/exception/AwsS3ErrorCode.java @@ -0,0 +1,21 @@ +package com.swyp3.babpool.infra.s3.exception; + + +import com.swyp3.babpool.global.common.exception.errorcode.CustomErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; +//import org.apache.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AwsS3ErrorCode implements CustomErrorCode { + + AWS_S3_IMAGE_UPLOAD_FAIL(HttpStatus.FAILED_DEPENDENCY, "Aws s3 image upload fail."), + AWS_S3_INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "Invalid file type. Only JPEG and PNG are supported."), + AWS_S3_FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "File size is too large. Maximum allowed size is 5MB."), + AWS_S3_IMAGE_DELETE_FAIL(HttpStatus.BAD_REQUEST, "Aws s3 image delete fail."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/infra/s3/exception/AwsS3Exception.java b/src/main/java/com/swyp3/babpool/infra/s3/exception/AwsS3Exception.java new file mode 100644 index 00000000..8ba649c4 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/s3/exception/AwsS3Exception.java @@ -0,0 +1,12 @@ +package com.swyp3.babpool.infra.s3.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class AwsS3Exception extends RuntimeException{ + + private final AwsS3ErrorCode errorCode; + private final String message; +} diff --git a/src/main/java/com/swyp3/babpool/infra/s3/exception/AwsS3ExceptionHandler.java b/src/main/java/com/swyp3/babpool/infra/s3/exception/AwsS3ExceptionHandler.java new file mode 100644 index 00000000..373053f5 --- /dev/null +++ b/src/main/java/com/swyp3/babpool/infra/s3/exception/AwsS3ExceptionHandler.java @@ -0,0 +1,18 @@ +package com.swyp3.babpool.infra.s3.exception; + +import com.swyp3.babpool.global.common.response.ApiErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class AwsS3ExceptionHandler { + + @ExceptionHandler(AwsS3Exception.class) + protected ApiErrorResponse handleAwsS3Exception(AwsS3Exception exception){ + log.error("AwsS3Exception getErrorCode() >> "+exception.getErrorCode()); + log.error("AwsS3Exception getMessage() >> "+exception.getMessage()); + return ApiErrorResponse.of(exception.getErrorCode()); + } +} diff --git a/src/main/resources/mapper/AuthMapper.xml b/src/main/resources/mapper/AuthMapper.xml new file mode 100644 index 00000000..507121dc --- /dev/null +++ b/src/main/resources/mapper/AuthMapper.xml @@ -0,0 +1,11 @@ + + + + + + insert into t_oauth(user_id,oauth_platform_name,oauth_platform_id) + values ( #{userId},#{oauthPlatformName},#{oauthPlatformId} ) + + + \ No newline at end of file diff --git a/src/main/resources/mapper/ProfileMapper.xml b/src/main/resources/mapper/ProfileMapper.xml new file mode 100644 index 00000000..642bd042 --- /dev/null +++ b/src/main/resources/mapper/ProfileMapper.xml @@ -0,0 +1,11 @@ + + + + + + INSERT INTO t_profile (profile_image_url) VALUES (#{profileImageUrl}) where user_id = #{userId} + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 00000000..6a2bb11b --- /dev/null +++ b/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,24 @@ + + + + + + insert into t_user_account(user_email,user_status,user_role,user_grade,user_nick_name,user_create_date,user_modify_date) + values ( #{userEmail},#{userStatus},#{userRole},#{userGrade},#{userNickName},#{userCreateDate},#{userModifyDate} ) + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/UserUuidMapper.xml b/src/main/resources/mapper/UserUuidMapper.xml new file mode 100644 index 00000000..e4e042f5 --- /dev/null +++ b/src/main/resources/mapper/UserUuidMapper.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + INSERT INTO t_user_uuid (user_id, user_uuid) VALUES (#{userId}, #{userUuid}) + + + \ No newline at end of file diff --git a/src/test/java/com/swyp3/babpool/global/util/jwt/JwtAuthenticatorTest.java b/src/test/java/com/swyp3/babpool/global/util/jwt/JwtAuthenticatorTest.java new file mode 100644 index 00000000..ecf9dff5 --- /dev/null +++ b/src/test/java/com/swyp3/babpool/global/util/jwt/JwtAuthenticatorTest.java @@ -0,0 +1,120 @@ +package com.swyp3.babpool.global.util.jwt; + +import com.swyp3.babpool.global.jwt.JwtAuthenticator; +import com.swyp3.babpool.global.jwt.JwtTokenizer; +import com.swyp3.babpool.global.uuid.dao.UserUuidRepository; +import com.swyp3.babpool.global.uuid.exception.UuidException; +import com.swyp3.babpool.global.uuid.util.UuidResolver; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.security.InvalidKeyException; +import io.jsonwebtoken.security.SignatureException; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.boot.test.autoconfigure.MybatisTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@ActiveProfiles("test") +@MybatisTest +class JwtAuthenticatorTest { + + private JwtAuthenticator jwtAuthenticator; + private JwtTokenizer jwtTokenizer; + private UuidResolver uuidResolver; + @Autowired + UserUuidRepository userUuidRepository; + + @BeforeEach + void setUp() { + jwtTokenizer = new JwtTokenizer("12345678901234567890123456789012", "12345678901234567890123456789012"); + uuidResolver = new UuidResolver(); + jwtAuthenticator = new JwtAuthenticator(jwtTokenizer, uuidResolver, userUuidRepository); + } + + @DisplayName("토큰을 파싱해 클레임을 반환한다. 올바른 토큰인 경우") + @Test + void authenticate_proper_access_token() { + // given + String userUUID = "46707d92-02f4-4817-8116-a4c3b23e6266"; + List roles = List.of("ROLE_USER"); + String accessToken = jwtTokenizer.createAccessToken(userUUID, roles); + log.info("accessToken: {}", accessToken); + // when + Claims claims = jwtAuthenticator.authenticate(accessToken); + log.info("claims.getSubject(): {}", claims.getSubject()); + // then + assertThat(claims).isNotEmpty(); + assertThat(claims.getSubject()).isEqualTo(userUUID); + } + + @DisplayName("토큰을 파싱해 클레임을 반환한다. 올바르지 않은 토큰인 경우") + @Test + void authenticate_improper_access_token() { + // given + String userUUID = "46707d92-02f4-4817-8116-a4c3b23e6266"; + List roles = List.of("ROLE_USER"); + String accessToken = jwtTokenizer.createAccessToken(userUUID, roles); + // when + // then + assertThrows(SignatureException.class, () -> { + jwtAuthenticator.authenticate(accessToken + "a"); + }); + } + + @DisplayName("토큰을 파싱해 클레임을 반환한다. 만료된 토큰인 경우") + @Test + void authenticate_expired_access_token() { + // given + String accessToken = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0NjcwN2Q5Mi0wMmY0LTQ4MTctODExNi1hNGMzYjIzZTYyNjYiLCJyb2xlcyI6WyJST0xFX1VTRVIiXSwiaWF0IjoxNzA4OTA5NDQ4LCJleHAiOjF9.YX_hZKjbewwz1FFaj6aOLPogUs1jPK5UC1aR1dHbB7A"; + // when + // then + assertThrows(ExpiredJwtException.class, () -> { + jwtAuthenticator.authenticate(accessToken); + }); + } + + @DisplayName("토큰을 파싱해 클레임을 반환한다. 토큰이 빈 문자열인 경우") + @Test + void authenticate_empty_access_token() { + // given + String accessToken = ""; + // when + // then + assertThrows(IllegalArgumentException.class, () -> { + jwtAuthenticator.authenticate(accessToken); + }); + } + + @DisplayName("토큰을 파싱해 클레임을 반환한다. 지원하지 않는 알고리즘의 토큰인 경우") + @Test + void authenticate_unsupported_access_token() { + // given + String accessToken = "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiI0NjcwN2Q5Mi0wMmY0LTQ4MTctODExNi1hNGMzYjIzZTYyNjYiLCJyb2xlcyI6WyJST0xFX1VTRVIiXSwiaWF0IjoxNzA4OTA5NDQ4LCJleHAiOjF9.MroKm4-jOBCvpVZayg2duSri8ZdLSc5au1N3Mhpgbr0Q_B_3D088pk1rCDkOKDfr"; + // when + // then + assertThrows(InvalidKeyException.class, () -> { + jwtAuthenticator.authenticate(accessToken); + }); + } + + @DisplayName("userUuid 으로 UserUuidRepository 에서 userId를 조회한다. 존재하지 않는 경우 예외가 발생한다.") + @Test + void findUserIdByUserUuid_not_exist() { + // given + String userUUID = "46707d92-02f4-4817-8116-a4c3b23e6266"; + // when + // then + assertThrows(UuidException.class, () -> { + jwtAuthenticator.jwtTokenUserIdResolver(userUUID); + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/swyp3/babpool/global/util/jwt/JwtTokenizerTest.java b/src/test/java/com/swyp3/babpool/global/util/jwt/JwtTokenizerTest.java new file mode 100644 index 00000000..24fb1cd4 --- /dev/null +++ b/src/test/java/com/swyp3/babpool/global/util/jwt/JwtTokenizerTest.java @@ -0,0 +1,89 @@ +package com.swyp3.babpool.global.util.jwt; + + +import com.swyp3.babpool.global.jwt.JwtTokenizer; +import io.jsonwebtoken.Claims; +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +class JwtTokenizerTest { + + JwtTokenizer jwtTokenizer; + + @BeforeEach + void setUp(){ + jwtTokenizer = new JwtTokenizer("12345678901234567890123456789012", "12345678901234567890123456789012"); + } + + @DisplayName("UUID와 Role 정보를 이용하여 AccessToken 생성한다.") + @Test + void createAccessToken() { + // given + String userUUID = "46707d92-02f4-4817-8116-a4c3b23e6266"; + List roles = List.of("ROLE_USER"); + // when + String accessToken = jwtTokenizer.createAccessToken(userUUID, roles); + // then + assertNotNull(accessToken); + log.info("accessToken: {}", accessToken); + Claims claims = jwtTokenizer.parseAccessToken(accessToken); + log.info("claims: {}", claims); + Assertions.assertThat(claims.getSubject()).isEqualTo(userUUID); + } + + @DisplayName("UUID와 Role 정보를 이용하여 RefreshToken 생성한다.") + @Test + void createRefreshToken() { + // given + String userUUID = "46707d92-02f4-4817-8116-a4c3b23e6267"; + List roles = List.of("ROLE_USER"); + // when + String refreshToken = jwtTokenizer.createRefreshToken(userUUID, roles); + // then + assertNotNull(refreshToken); + log.info("refreshToken: {}", refreshToken); + Claims claims = jwtTokenizer.parseAccessToken(refreshToken); + log.info("claims: {}", claims); + Assertions.assertThat(claims.getSubject()).isEqualTo(userUUID); + } + + @DisplayName("AccessToken을 파싱하여 Claims 정보를 추출한다.") + @Test + void parseAccessToken() { + // given + String userUUID = "46707d92-02f4-4817-8116-a4c3b23e6268"; + List roles = List.of("ROLE_USER"); + String accessToken = jwtTokenizer.createAccessToken(userUUID, roles); + // when + Claims claims = jwtTokenizer.parseAccessToken(accessToken); + // then + assertNotNull(claims); + log.info("claims: {}", claims); + Assertions.assertThat(claims.getSubject()).isEqualTo(userUUID); + } + + @DisplayName("RefreshToken을 파싱하여 Claims 정보를 추출한다.") + @Test + void parseRefreshToken() { + // given + String userUUID = "46707d92-02f4-4817-8116-a4c3b23e6269"; + List roles = List.of("ROLE_USER"); + String refreshToken = jwtTokenizer.createRefreshToken(userUUID, roles); + // when + Claims claims = jwtTokenizer.parseRefreshToken(refreshToken); + // then + assertNotNull(claims); + log.info("claims: {}", claims); + Assertions.assertThat(claims.getSubject()).isEqualTo(userUUID); + } + + +} \ No newline at end of file diff --git a/src/test/java/com/swyp3/babpool/global/util/jwt/application/JwtServiceImplTest.java b/src/test/java/com/swyp3/babpool/global/util/jwt/application/JwtServiceImplTest.java new file mode 100644 index 00000000..c7b97ff3 --- /dev/null +++ b/src/test/java/com/swyp3/babpool/global/util/jwt/application/JwtServiceImplTest.java @@ -0,0 +1,97 @@ +package com.swyp3.babpool.global.util.jwt.application; + + +import com.swyp3.babpool.global.jwt.JwtTokenizer; +import com.swyp3.babpool.global.jwt.application.JwtServiceImpl; +import com.swyp3.babpool.global.jwt.application.response.JwtPairDto; +import com.swyp3.babpool.infra.redis.EmbeddedLocalRedisConfig; +import com.swyp3.babpool.infra.redis.RedisRepositoryConfig; +import com.swyp3.babpool.infra.redis.dao.TokenRedisRepository; +import com.swyp3.babpool.infra.redis.domain.TokenForRedis; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@ActiveProfiles("test") +@SpringBootTest(classes = {JwtServiceImpl.class, TokenRedisRepository.class, JwtTokenizer.class, EmbeddedLocalRedisConfig.class, RedisRepositoryConfig.class}) +class JwtServiceImplTest { + + @Autowired + JwtServiceImpl jwtService; + @Autowired + TokenRedisRepository tokenRepository; + + @DisplayName("jwt token 쌍이 저장된 jwtPairDto 가 반환된다") + @Test + void createJwtPair() { + // given + String userUUID = "46707d92-02f4-4817-8116-a4c3b23e6266"; + List roles = List.of("ROLE_USER"); + // when + JwtPairDto jwtPairDto = jwtService.createJwtPair(userUUID, roles); + // then + assertNotNull(jwtPairDto); + log.info("jwtPairDto : {}", jwtPairDto); + tokenRepository.findById(userUUID).ifPresent(tokenForRedis -> { + log.info("tokenForRedis : {}", tokenForRedis); + assertEquals(jwtPairDto.getRefreshToken(), tokenForRedis.getRefreshToken()); + }); + } + + @DisplayName("refreshToken 으로 accessToken 을 갱신한다") + @Test + void extendLoginState() { + // given + String uuid = "0123456789"; + List roles = List.of("ROLE_USER"); + Integer refreshExpire = 7; + + JwtTokenizer jwtTokenizer = jwtTokenizer + = new JwtTokenizer("12345678901234567890123456789012","12345678901234567890123456789012"); + String refreshToken = jwtTokenizer.createRefreshToken(uuid, roles); + + tokenRepository.save(TokenForRedis.builder() + .refreshToken(refreshToken) + .refreshExpire(refreshExpire) + .userUUID(uuid) + .build()); + // when + String accessToken = jwtService.extendLoginState(refreshToken); + // then + assertNotNull(accessToken); + assertThat(jwtTokenizer.parseRefreshToken(refreshToken).getSubject()) + .isEqualTo(jwtTokenizer.parseAccessToken(accessToken).getSubject()); + } + + @DisplayName("refreshToken 으로 로그아웃 처리하면, Redis 에서도 삭제된다.") + @Test + void logout() { + // given + String uuid = "0123456789"; + List roles = List.of("ROLE_USER"); + Integer refreshExpire = 7; + + JwtTokenizer jwtTokenizer = jwtTokenizer + = new JwtTokenizer("12345678901234567890123456789012","12345678901234567890123456789012"); + String refreshToken = jwtTokenizer.createRefreshToken(uuid, roles); + + tokenRepository.save(TokenForRedis.builder() + .refreshToken(refreshToken) + .refreshExpire(refreshExpire) + .userUUID(uuid) + .build()); + // when + jwtService.logout(refreshToken); + // then + assertThat(tokenRepository.findById(refreshToken)).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/swyp3/babpool/global/uuid/application/UuidResolverTest.java b/src/test/java/com/swyp3/babpool/global/uuid/application/UuidResolverTest.java new file mode 100644 index 00000000..f505edbe --- /dev/null +++ b/src/test/java/com/swyp3/babpool/global/uuid/application/UuidResolverTest.java @@ -0,0 +1,105 @@ +package com.swyp3.babpool.global.uuid.application; + +import com.fasterxml.uuid.Generators; +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +class UuidResolverTest { + + /* + * 참고 링크 + * - https://www.baeldung.com/java-generating-time-based-uuids + * - https://chanos.tistory.com/entry/MySQL-UUID%EB%A5%BC-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9C%BC%EB%A1%9C-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0-%EC%9C%84%ED%95%9C-%EB%85%B8%EB%A0%A5%EA%B3%BC-%ED%95%9C%EA%B3%84 + * */ + + private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); + + @DisplayName("UUID byte 크기 확인") + @Test + void reduceUuidByteSize(){ + UUID generate = Generators.timeBasedGenerator().generate(); + int length = generate.toString().getBytes().length; + System.out.println(generate+ " length = " + length); + } + + @DisplayName("v1 UUID 생성 test") + @Test + void generateV1Uuid() { + for (int i = 0; i < 50; i++) { + log.info("UUID Version 1: {}", Generators.timeBasedGenerator().generate()); + } + } + + @DisplayName("v6 UUID 생성 test") + @Test + void generateV6Uuid() { + for (int i = 0; i < 50; i++) { + log.info("UUID Version 6: {}", Generators.timeBasedReorderedGenerator().generate()); + } + } + + @DisplayName("v7 UUID 생성 test") + @Test + void generateV7Uuid() { + for (int i = 0; i < 50; i++) { + log.info("UUID Version 7: {}", Generators.timeBasedEpochGenerator().generate()); + } + } + + @DisplayName("UUID를 바이트 배열로 변환") + @Test + void uuidStringToByte() { + for (int i = 0; i < 50; i++) { + UUID uuid = Generators.timeBasedGenerator().generate(); + ByteBuffer bb = ByteBuffer.wrap(new byte[16]); + bb.putLong(uuid.getMostSignificantBits()); + bb.putLong(uuid.getLeastSignificantBits()); + log.info("UUID: {} >> Bytes: {}", uuid, bb.array()); + } + } + + @DisplayName("UUID를 Bitwise 연산으로 바이트 배열로 변환") + @Test + void uuidStringToByteBitwise() { + for (int i = 0; i < 50; i++) { + UUID uuid = Generators.timeBasedGenerator().generate(); + long mostSignificantBits = uuid.getMostSignificantBits(); + long leastSignificantBits = uuid.getLeastSignificantBits(); + long result = Math.abs((mostSignificantBits << 32) | (leastSignificantBits & 0xFFFFFFFFL)); + log.info("UUID: {} >> Abs Bytes: {}", uuid, result); + } + } + + @DisplayName("바이트 배열로 변환된 UUID를 16진수 문자열로 변환") + @Test + void bytesToHex() { + + UUID uuid = Generators.timeBasedGenerator().generate(); + ByteBuffer bb = ByteBuffer.wrap(new byte[16]); + bb.putLong(uuid.getMostSignificantBits()); + bb.putLong(uuid.getLeastSignificantBits()); + byte[] result = bb.array(); + log.info("UUID: {} >> Bytes: {}", uuid, result); + + ByteBuffer byteBuffer = ByteBuffer.wrap(result); + long mostSignificantBitsOrigin = byteBuffer.getLong(); + long leastSignificantBitsOrigin = byteBuffer.getLong(); + + UUID originalUUID = new UUID(mostSignificantBitsOrigin, leastSignificantBitsOrigin); + Assertions.assertThat(uuid).isEqualTo(originalUUID); + } + + + + + +} \ No newline at end of file diff --git a/src/test/java/com/swyp3/babpool/global/uuid/application/UuidServiceV7Test.java b/src/test/java/com/swyp3/babpool/global/uuid/application/UuidServiceV7Test.java new file mode 100644 index 00000000..4635e124 --- /dev/null +++ b/src/test/java/com/swyp3/babpool/global/uuid/application/UuidServiceV7Test.java @@ -0,0 +1,62 @@ +package com.swyp3.babpool.global.uuid.application; + +import com.swyp3.babpool.global.uuid.dao.UserUuidRepository; +import com.swyp3.babpool.global.uuid.domain.UserUuid; +import com.swyp3.babpool.global.uuid.util.UuidResolver; +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.boot.test.autoconfigure.MybatisTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@MybatisTest +class UuidServiceV7Test { + + private UuidServiceV7 uuidServiceV7; + + @Autowired + private UserUuidRepository userUuidRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + + @BeforeEach + void setUp() { + jdbcTemplate.update("insert into t_user_account(user_id, user_email,user_status,user_role,user_grade,user_nick_name,user_create_date,user_modify_date)\n" + + " values (1, 'test@test.com','active','user','first','test',NOW(),NOW())"); + uuidServiceV7 = new UuidServiceV7(new UuidResolver(), userUuidRepository); + } + + @Test + void createUuid() { + // given + Long userId = 1L; + UuidResolver uuidResolver = new UuidResolver(); + // when + UUID createdUuid = uuidServiceV7.createUuid(userId); + // then + log.info("UUID : {}", createdUuid); + assertNotNull(createdUuid); + Optional insertedUserUuid = userUuidRepository.findByUserUuIdBytes(uuidResolver.parseUuidToBytes(createdUuid)); + log.info("insertedUserUuid : {}", insertedUserUuid.toString()); + Assertions.assertThat(insertedUserUuid.get().getUserId()).isEqualTo(userId); + Assertions.assertThat(uuidResolver.parseBytesToUuid(insertedUserUuid.get().getUserUuid())).isEqualTo(createdUuid); + log.info("insertedUserUuid parseBytes to UUID : {}", uuidResolver.parseBytesToUuid(insertedUserUuid.get().getUserUuid())); + + } + + +} \ No newline at end of file diff --git a/src/test/java/com/swyp3/babpool/infra/auth/KakaoClientTest.java b/src/test/java/com/swyp3/babpool/infra/auth/KakaoClientTest.java new file mode 100644 index 00000000..0c3d55cb --- /dev/null +++ b/src/test/java/com/swyp3/babpool/infra/auth/KakaoClientTest.java @@ -0,0 +1,48 @@ +package com.swyp3.babpool.infra.auth; + +import com.swyp3.babpool.global.config.FeignClientConfig; +import com.swyp3.babpool.infra.auth.BabpoolPublicKey; +import com.swyp3.babpool.infra.auth.kakao.KakaoClient; +import com.swyp3.babpool.infra.auth.kakao.KakaoPublicKeys; +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.net.http.HttpClient; +import java.util.List; +import java.util.Objects; + +@Slf4j +@SpringBootTest +public class KakaoClientTest { + @Autowired + private KakaoClient kakaoClient; + + @Test + @DisplayName("KAKAO 서버와 통신하여 Kakao public keys 응답을 받는다") + void getPublicKeys(){ + KakaoPublicKeys kakaoPublicKeys = kakaoClient.getKakaoOIDCOpenKeys(); + List keys = kakaoPublicKeys.getKeys(); + + boolean isRequestedKeysNonNull = keys.stream() + .allMatch(this::isAllNotNull); + Assertions.assertThat(isRequestedKeysNonNull).isTrue(); + + log.info("kid1: {}", keys.get(0).getKid()); + log.info("alg1: {}", keys.get(0).getAlg()); + log.info("kid2: {}", keys.get(1).getKid()); + log.info("alg2: {}", keys.get(1).getAlg()); + } + + private boolean isAllNotNull(BabpoolPublicKey babpoolPublicKey) { + return Objects.nonNull(babpoolPublicKey.getKty()) && Objects.nonNull(babpoolPublicKey.getKid()) && + Objects.nonNull(babpoolPublicKey.getUse()) && Objects.nonNull(babpoolPublicKey.getAlg()) && + Objects.nonNull(babpoolPublicKey.getN()) && Objects.nonNull(babpoolPublicKey.getE()); + } +} diff --git a/src/test/java/com/swyp3/babpool/infra/redis/dao/TokenRedisRepositoryTest.java b/src/test/java/com/swyp3/babpool/infra/redis/dao/TokenRedisRepositoryTest.java new file mode 100644 index 00000000..149915e1 --- /dev/null +++ b/src/test/java/com/swyp3/babpool/infra/redis/dao/TokenRedisRepositoryTest.java @@ -0,0 +1,45 @@ +package com.swyp3.babpool.infra.redis.dao; + +import com.swyp3.babpool.infra.redis.EmbeddedLocalRedisConfig; +import com.swyp3.babpool.infra.redis.RedisRepositoryConfig; +import com.swyp3.babpool.infra.redis.domain.TokenForRedis; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@Import({EmbeddedLocalRedisConfig.class, RedisRepositoryConfig.class}) +@DataRedisTest +@ActiveProfiles("test") +class TokenRedisRepositoryTest { + + @Autowired + private TokenRedisRepository tokenRedisRepository; + + @DisplayName("토큰 저장이 정상적으로 동작하는지 확인한다.") + @Test + void save() { + // given + String refreshToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMTIzNDU2Nzg5Iiwicm9sZXMiOiJST0xFX1VTRVIiLCJpYXQiOjE1MTYyMzkwMjJ9.tac5NQLH2MmB-CmUvYllf2ftt2VnxTGRPxd2fHDopyY"; + TokenForRedis target = TokenForRedis.builder() + .userUUID("0123456789") + .refreshToken(refreshToken) + .refreshExpire(10) + .build(); + // when + TokenForRedis savedToken = tokenRedisRepository.save(target); + + // then + tokenRedisRepository.findById(refreshToken) + .ifPresent(token -> { + assertThat(target.getUserUUID()).isEqualTo(savedToken.getUserUUID()); + assertThat(target.getRefreshToken()).isEqualTo(savedToken.getRefreshToken()); + assertThat(target.getRefreshExpire()).isEqualTo(savedToken.getRefreshExpire()); + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/swyp3/babpool/infra/s3/application/AwsS3ProviderTest.java b/src/test/java/com/swyp3/babpool/infra/s3/application/AwsS3ProviderTest.java new file mode 100644 index 00000000..fa3d17a6 --- /dev/null +++ b/src/test/java/com/swyp3/babpool/infra/s3/application/AwsS3ProviderTest.java @@ -0,0 +1,22 @@ +package com.swyp3.babpool.infra.s3.application; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +class AwsS3ProviderTest { + + @DisplayName("S3 이미지 삭제 요청 URL에서 파일명 추출 테스트") + @Test + void extractFileNameFromDeleteRequestUrl() { + + String imageUrlFromProfileRepository = "https://babpool-image-bucket.s3.ap-northeast-2.amazonaws.com/static/018df2dd-6b59-7758-9e76-2862767ce099.center-username-notion-avatar-1707464623232.png"; + String fileName = "static" + imageUrlFromProfileRepository.substring(imageUrlFromProfileRepository.lastIndexOf("/")); + log.info("fileName = {}", fileName); + } + + +} \ No newline at end of file