diff --git a/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java b/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java index 660708a3..d1ce792c 100644 --- a/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java +++ b/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java @@ -13,8 +13,7 @@ public enum AuthRedisKey implements RedisKeys { TOKEN_BLACKLIST("auth:token:blacklist:%s", -1), PASSWORD_RESET_TOKEN("auth:password_reset:token:%s", PasswordResetConstants.TOKEN_TTL_MINUTES * 60), PASSWORD_RESET_EMAIL("auth:password_reset:email:%s", PasswordResetConstants.TOKEN_TTL_MINUTES * 60), - SOCIAL_LINK_TOKEN("auth:social:link_token:%s", 180), - ; + SOCIAL_LINK_TOKEN("auth:social:link_token:%s", 180); private final String pattern; private final int ttlSeconds; diff --git a/src/main/java/project/flipnote/auth/controller/AuthController.java b/src/main/java/project/flipnote/auth/controller/AuthController.java index 27cca43c..95d0d50b 100644 --- a/src/main/java/project/flipnote/auth/controller/AuthController.java +++ b/src/main/java/project/flipnote/auth/controller/AuthController.java @@ -1,10 +1,15 @@ package project.flipnote.auth.controller; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -12,6 +17,8 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import project.flipnote.auth.controller.docs.AuthControllerDocs; +import project.flipnote.auth.model.ChangePasswordRequest; import project.flipnote.auth.model.EmailVerificationConfirmRequest; import project.flipnote.auth.model.EmailVerificationRequest; import project.flipnote.auth.model.PasswordResetCreateRequest; @@ -19,20 +26,30 @@ import project.flipnote.auth.model.TokenPair; import project.flipnote.auth.model.UserLoginRequest; import project.flipnote.auth.model.UserLoginResponse; +import project.flipnote.auth.model.UserRegisterRequest; +import project.flipnote.auth.model.UserRegisterResponse; import project.flipnote.auth.service.AuthService; +import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.common.security.jwt.JwtConstants; import project.flipnote.common.security.jwt.JwtProperties; import project.flipnote.common.util.CookieUtil; +import project.flipnote.user.model.SocialLinksResponse; @RequiredArgsConstructor @RestController @RequestMapping("/v1/auth") -public class AuthController { +public class AuthController implements AuthControllerDocs { private final AuthService authService; private final JwtProperties jwtProperties; private final CookieUtil cookieUtil; + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody UserRegisterRequest req) { + UserRegisterResponse res = authService.register(req); + return ResponseEntity.status(HttpStatus.CREATED).body(res); + } + @PostMapping("/login") public ResponseEntity login( @Valid @RequestBody UserLoginRequest req @@ -111,4 +128,33 @@ public ResponseEntity resetPassword( return ResponseEntity.noContent().build(); } + + @PatchMapping("/password") + public ResponseEntity updatePassword( + @AuthenticationPrincipal AuthPrinciple userAuth, + @Valid @RequestBody ChangePasswordRequest req + ) { + authService.changePassword(userAuth.authId(), req); + + return ResponseEntity.noContent().build(); + } + + @GetMapping("/social-links") + public ResponseEntity getSocialLinks( + @AuthenticationPrincipal AuthPrinciple userAuth + ) { + SocialLinksResponse res = authService.getSocialLinks(userAuth.authId()); + + return ResponseEntity.ok(res); + } + + @DeleteMapping("/social-links/{socialLinkId}") + public ResponseEntity deleteSocialLink( + @AuthenticationPrincipal AuthPrinciple userAuth, + @PathVariable("socialLinkId") Long socialLinkId + ) { + authService.deleteSocialLink(userAuth.authId(), socialLinkId); + + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/project/flipnote/auth/controller/OAuthController.java b/src/main/java/project/flipnote/auth/controller/OAuthController.java index 00e4e0c2..2855af0e 100644 --- a/src/main/java/project/flipnote/auth/controller/OAuthController.java +++ b/src/main/java/project/flipnote/auth/controller/OAuthController.java @@ -19,13 +19,14 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import project.flipnote.auth.constants.OAuthConstants; +import project.flipnote.auth.controller.docs.OAuthControllerDocs; import project.flipnote.auth.exception.AuthErrorCode; import project.flipnote.auth.model.AuthorizationRedirect; import project.flipnote.auth.model.TokenPair; import project.flipnote.auth.service.OAuthService; import project.flipnote.common.config.ClientProperties; import project.flipnote.common.exception.BizException; -import project.flipnote.common.security.dto.UserAuth; +import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.common.security.jwt.JwtConstants; import project.flipnote.common.security.jwt.JwtProperties; import project.flipnote.common.util.CookieUtil; @@ -33,7 +34,7 @@ @Slf4j @RequiredArgsConstructor @RestController -public class OAuthController { +public class OAuthController implements OAuthControllerDocs { private final OAuthService oAuthService; private final ClientProperties clientProperties; @@ -44,7 +45,7 @@ public class OAuthController { public ResponseEntity redirectToProviderAuthorization( @PathVariable("provider") String provider, HttpServletRequest request, - @AuthenticationPrincipal UserAuth userAuth + @AuthenticationPrincipal AuthPrinciple userAuth ) { AuthorizationRedirect authRedirect = oAuthService.getAuthorizationUri(provider, request, userAuth); diff --git a/src/main/java/project/flipnote/auth/controller/docs/AuthControllerDocs.java b/src/main/java/project/flipnote/auth/controller/docs/AuthControllerDocs.java new file mode 100644 index 00000000..3fc8e893 --- /dev/null +++ b/src/main/java/project/flipnote/auth/controller/docs/AuthControllerDocs.java @@ -0,0 +1,53 @@ +package project.flipnote.auth.controller.docs; + +import org.springframework.http.ResponseEntity; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import project.flipnote.auth.model.ChangePasswordRequest; +import project.flipnote.auth.model.EmailVerificationConfirmRequest; +import project.flipnote.auth.model.EmailVerificationRequest; +import project.flipnote.auth.model.PasswordResetCreateRequest; +import project.flipnote.auth.model.PasswordResetRequest; +import project.flipnote.auth.model.UserLoginRequest; +import project.flipnote.auth.model.UserLoginResponse; +import project.flipnote.auth.model.UserRegisterRequest; +import project.flipnote.auth.model.UserRegisterResponse; +import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.user.model.SocialLinksResponse; + +public interface AuthControllerDocs { + + @Operation(summary = "회원가입") + ResponseEntity register(UserRegisterRequest req); + + @Operation(summary = "로그인") + ResponseEntity login(UserLoginRequest req); + + @Operation(summary = "로그아웃", security = { @SecurityRequirement(name = "access-token") }) + ResponseEntity logout(); + + @Operation(summary = "이메일 인증번호 전송") + ResponseEntity sendEmailVerificationCode(EmailVerificationRequest req); + + @Operation(summary = "이메일 인증번호 확인") + ResponseEntity confirmEmailVerificationCode(EmailVerificationConfirmRequest req); + + @Operation(summary = "토큰 갱신") + ResponseEntity refreshToken(String refreshToken); + + @Operation(summary = "비밀번호 재설정 링크 전송") + ResponseEntity requestPasswordReset(PasswordResetCreateRequest req); + + @Operation(summary = "비밀번호 재설정") + ResponseEntity resetPassword(PasswordResetRequest req); + + @Operation(summary = "내 비밀번호 변경", security = { @SecurityRequirement(name = "access-token") }) + ResponseEntity updatePassword(AuthPrinciple userAuth, ChangePasswordRequest req); + + @Operation(summary = "내 소셜 연동 계정 목록 조회", security = { @SecurityRequirement(name = "access-token") }) + ResponseEntity getSocialLinks(AuthPrinciple userAuth); + + @Operation(summary = "소셜 연동 해제", security = { @SecurityRequirement(name = "access-token") }) + ResponseEntity deleteSocialLink(AuthPrinciple userAuth, Long socialLinkId); +} diff --git a/src/main/java/project/flipnote/auth/controller/docs/OAuthControllerDocs.java b/src/main/java/project/flipnote/auth/controller/docs/OAuthControllerDocs.java new file mode 100644 index 00000000..34f886bb --- /dev/null +++ b/src/main/java/project/flipnote/auth/controller/docs/OAuthControllerDocs.java @@ -0,0 +1,28 @@ +package project.flipnote.auth.controller.docs; + +import org.springframework.http.ResponseEntity; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import project.flipnote.common.security.dto.AuthPrinciple; + +@Tag(name = "OAuth", description = "OAuth API") +public interface OAuthControllerDocs { + + @Operation(summary = "소셜 인증 URL로 리다이렉트") + ResponseEntity redirectToProviderAuthorization( + String provider, + HttpServletRequest request, + AuthPrinciple userAuth + ); + + @Operation(summary = "소셜 계정 연동 및 로그인") + ResponseEntity handleCallback( + String provider, + String code, + String state, + String codeVerifier, + HttpServletRequest request + ); +} diff --git a/src/main/java/project/flipnote/auth/entity/AccountRole.java b/src/main/java/project/flipnote/auth/entity/AccountRole.java new file mode 100644 index 00000000..7d04f625 --- /dev/null +++ b/src/main/java/project/flipnote/auth/entity/AccountRole.java @@ -0,0 +1,14 @@ +package project.flipnote.auth.entity; + +import java.util.Arrays; + +public enum AccountRole { + USER, ADMIN; + + public static AccountRole from(String role) { + return Arrays.stream(AccountRole.values()) + .filter(r -> r.name().equalsIgnoreCase(role)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/project/flipnote/auth/entity/AccountStatus.java b/src/main/java/project/flipnote/auth/entity/AccountStatus.java new file mode 100644 index 00000000..9928adc7 --- /dev/null +++ b/src/main/java/project/flipnote/auth/entity/AccountStatus.java @@ -0,0 +1,8 @@ +package project.flipnote.auth.entity; + +import lombok.Getter; + +@Getter +public enum AccountStatus { + ACTIVE, WITHDRAWN; +} diff --git a/src/main/java/project/flipnote/user/entity/UserOAuthLink.java b/src/main/java/project/flipnote/auth/entity/OAuthLink.java similarity index 80% rename from src/main/java/project/flipnote/user/entity/UserOAuthLink.java rename to src/main/java/project/flipnote/auth/entity/OAuthLink.java index 4ed6cdee..37ecd7a9 100644 --- a/src/main/java/project/flipnote/user/entity/UserOAuthLink.java +++ b/src/main/java/project/flipnote/auth/entity/OAuthLink.java @@ -1,4 +1,4 @@ -package project.flipnote.user.entity; +package project.flipnote.auth.entity; import java.time.LocalDateTime; @@ -21,19 +21,20 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import project.flipnote.user.entity.UserProfile; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @EntityListeners(AuditingEntityListener.class) @Table( - name = "user_oauth_link", + name = "oauth_link", indexes = { @Index(name = "idx_provider_provider_id", columnList = "provider, providerId") } ) @Entity -public class UserOAuthLink { +public class OAuthLink { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -46,17 +47,17 @@ public class UserOAuthLink { private String providerId; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; + @JoinColumn(name = "auth_id", nullable = false) + private UserAuth userAuth; @CreatedDate @Column(updatable = false) private LocalDateTime linkedAt; @Builder - public UserOAuthLink(String provider, String providerId, User user) { + public OAuthLink(String provider, String providerId, UserAuth userAuth) { this.provider = provider; this.providerId = providerId; - this.user = user; + this.userAuth = userAuth; } } diff --git a/src/main/java/project/flipnote/auth/entity/UserAuth.java b/src/main/java/project/flipnote/auth/entity/UserAuth.java new file mode 100644 index 00000000..fa0d4c7b --- /dev/null +++ b/src/main/java/project/flipnote/auth/entity/UserAuth.java @@ -0,0 +1,76 @@ +package project.flipnote.auth.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import project.flipnote.common.entity.SoftDeletableEntity; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_auth") +@Entity +public class UserAuth extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + private String email; + + private String password; + + @Column(unique = true, nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AccountStatus status; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AccountRole role; + + @Column(nullable = false) + private long tokenVersion; + + @Builder + public UserAuth( + String email, + String password, + Long userId + ) { + this.email = email; + this.password = password; + this.userId = userId; + this.status = AccountStatus.ACTIVE; + this.role = AccountRole.USER; + this.tokenVersion = 0L; + } + + public void withdraw() { + super.softDelete(); + + this.status = AccountStatus.WITHDRAWN; + + increaseTokenVersion(); + } + + public void increaseTokenVersion() { + this.tokenVersion++; + } + + public void changePassword(String password) { + this.password = password; + } +} diff --git a/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java b/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java index 4b2bafb9..fa973599 100644 --- a/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java +++ b/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java @@ -22,7 +22,8 @@ public enum AuthErrorCode implements ErrorCode { INVALID_SOCIAL_LINK_TOKEN(HttpStatus.NOT_FOUND, "AUTH_010", "소셜 계정 연동 토큰이 유효하지 않거나 만료되었습니다."), ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_011", "이미 연동된 소셜 계정입니다."), NOT_REGISTERED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_012", "가입되지 않은 소셜 계정입니다."), - INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_013", "지원하지 않는 소셜 제공자입니다."); + INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_013", "지원하지 않는 소셜 제공자입니다."), + SOCIAL_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH_014", "소셜 연동 계정이 존재하지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java b/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java new file mode 100644 index 00000000..cb2dac6b --- /dev/null +++ b/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java @@ -0,0 +1,41 @@ +package project.flipnote.auth.listener; + +import org.springframework.context.event.EventListener; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.auth.entity.AccountStatus; +import project.flipnote.auth.repository.UserAuthRepository; +import project.flipnote.common.event.UserWithdrawnEvent; + +@Slf4j +@RequiredArgsConstructor +@Component +public class UserWithdrawnEventListener { + + private final UserAuthRepository userAuthRepository; + + @Async + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @EventListener + public void handleUserWithdrawnEvent(UserWithdrawnEvent event) { + userAuthRepository.findByUserIdAndStatus(event.userId(), AccountStatus.ACTIVE) + .ifPresent(userAuth -> { + userAuth.withdraw(); + userAuthRepository.save(userAuth); + }); + } + + @Recover + public void recover(Exception ex, UserWithdrawnEvent event) { + log.error("회원 탈퇴 상태 변경 실패: userId={}", event.userId(), ex); + } +} diff --git a/src/main/java/project/flipnote/user/model/ChangePasswordRequest.java b/src/main/java/project/flipnote/auth/model/ChangePasswordRequest.java similarity index 83% rename from src/main/java/project/flipnote/user/model/ChangePasswordRequest.java rename to src/main/java/project/flipnote/auth/model/ChangePasswordRequest.java index 18a38992..acc65841 100644 --- a/src/main/java/project/flipnote/user/model/ChangePasswordRequest.java +++ b/src/main/java/project/flipnote/auth/model/ChangePasswordRequest.java @@ -1,4 +1,4 @@ -package project.flipnote.user.model; +package project.flipnote.auth.model; import project.flipnote.common.validation.annotation.ValidPassword; diff --git a/src/main/java/project/flipnote/user/model/UserRegisterRequest.java b/src/main/java/project/flipnote/auth/model/UserRegisterRequest.java similarity index 73% rename from src/main/java/project/flipnote/user/model/UserRegisterRequest.java rename to src/main/java/project/flipnote/auth/model/UserRegisterRequest.java index 4e175c24..62f197c7 100644 --- a/src/main/java/project/flipnote/user/model/UserRegisterRequest.java +++ b/src/main/java/project/flipnote/auth/model/UserRegisterRequest.java @@ -1,8 +1,9 @@ -package project.flipnote.user.model; +package project.flipnote.auth.model; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import project.flipnote.common.dto.UserCreateCommand; import project.flipnote.common.util.PhoneUtil; import project.flipnote.common.validation.annotation.ValidPassword; import project.flipnote.common.validation.annotation.ValidPhone; @@ -32,4 +33,8 @@ public record UserRegisterRequest( public String getNormalizedPhone() { return PhoneUtil.normalize(phone); } + + public UserCreateCommand toCommand() { + return new UserCreateCommand(email, name, nickname, smsAgree, getNormalizedPhone(), profileImageUrl); + } } diff --git a/src/main/java/project/flipnote/user/model/UserRegisterResponse.java b/src/main/java/project/flipnote/auth/model/UserRegisterResponse.java similarity index 81% rename from src/main/java/project/flipnote/user/model/UserRegisterResponse.java rename to src/main/java/project/flipnote/auth/model/UserRegisterResponse.java index 9b9f1a2a..be8fb659 100644 --- a/src/main/java/project/flipnote/user/model/UserRegisterResponse.java +++ b/src/main/java/project/flipnote/auth/model/UserRegisterResponse.java @@ -1,4 +1,4 @@ -package project.flipnote.user.model; +package project.flipnote.auth.model; public record UserRegisterResponse( Long userId diff --git a/src/main/java/project/flipnote/auth/repository/OAuthLinkRepository.java b/src/main/java/project/flipnote/auth/repository/OAuthLinkRepository.java new file mode 100644 index 00000000..d2000760 --- /dev/null +++ b/src/main/java/project/flipnote/auth/repository/OAuthLinkRepository.java @@ -0,0 +1,32 @@ +package project.flipnote.auth.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import project.flipnote.auth.entity.OAuthLink; + +public interface OAuthLinkRepository extends JpaRepository { + + boolean existsByUserAuth_IdAndProviderId(Long authId, String providerId); + + List findByUserAuth_Id(Long authId); + + boolean existsByIdAndUserAuth_Id(Long id, Long authId); + + @Query(""" + SELECT uol + FROM OAuthLink uol + JOIN FETCH uol.userAuth + WHERE uol.provider = :provider + AND uol.providerId = :providerId + """) + Optional findByProviderAndProviderIdWithUserAuth( + @Param("provider") String provider, + @Param("providerId") String providerId + ); + +} diff --git a/src/main/java/project/flipnote/auth/repository/SocialLinkTokenRedisRepository.java b/src/main/java/project/flipnote/auth/repository/SocialLinkTokenRedisRepository.java index 8b461ae4..329f619c 100644 --- a/src/main/java/project/flipnote/auth/repository/SocialLinkTokenRedisRepository.java +++ b/src/main/java/project/flipnote/auth/repository/SocialLinkTokenRedisRepository.java @@ -15,19 +15,19 @@ public class SocialLinkTokenRedisRepository { private final StringRedisTemplate stringRedisTemplate; - public void saveToken(long userId, String token) { + public void saveToken(long authId, String token) { String key = AuthRedisKey.SOCIAL_LINK_TOKEN.key(token); Duration ttl = AuthRedisKey.SOCIAL_LINK_TOKEN.getTtl(); - stringRedisTemplate.opsForValue().set(key, String.valueOf(userId), ttl); + stringRedisTemplate.opsForValue().set(key, String.valueOf(authId), ttl); } - public Optional findUserIdByToken(String token) { + public Optional findAuthIdByToken(String token) { String key = AuthRedisKey.SOCIAL_LINK_TOKEN.key(token); - String userId = stringRedisTemplate.opsForValue().get(key); + String authId = stringRedisTemplate.opsForValue().get(key); - return Optional.ofNullable(userId) + return Optional.ofNullable(authId) .map(Long::parseLong); } diff --git a/src/main/java/project/flipnote/auth/repository/TokenVersionRedisRepository.java b/src/main/java/project/flipnote/auth/repository/TokenVersionRedisRepository.java index 13dc1241..04c5a142 100644 --- a/src/main/java/project/flipnote/auth/repository/TokenVersionRedisRepository.java +++ b/src/main/java/project/flipnote/auth/repository/TokenVersionRedisRepository.java @@ -15,22 +15,22 @@ public class TokenVersionRedisRepository { private final RedisTemplate tokenVersionRedisTemplate; - public void saveTokenVersion(long userId, long tokenVersion) { - String key = AuthRedisKey.TOKEN_VERSION.key(userId); + public void saveTokenVersion(long authId, long tokenVersion) { + String key = AuthRedisKey.TOKEN_VERSION.key(authId); Duration ttl = AuthRedisKey.TOKEN_VERSION.getTtl(); tokenVersionRedisTemplate.opsForValue().set(key, tokenVersion, ttl); } - public Optional getTokenVersion(long userId) { - String key = AuthRedisKey.TOKEN_VERSION.key(userId); + public Optional getTokenVersion(long authId) { + String key = AuthRedisKey.TOKEN_VERSION.key(authId); Long value = tokenVersionRedisTemplate.opsForValue().get(key); return Optional.ofNullable(value); } - public void deleteTokenVersion(long userId) { - String key = AuthRedisKey.TOKEN_VERSION.key(userId); + public void deleteTokenVersion(long authId) { + String key = AuthRedisKey.TOKEN_VERSION.key(authId); tokenVersionRedisTemplate.delete(key); } diff --git a/src/main/java/project/flipnote/auth/repository/UserAuthRepository.java b/src/main/java/project/flipnote/auth/repository/UserAuthRepository.java new file mode 100644 index 00000000..62b21b85 --- /dev/null +++ b/src/main/java/project/flipnote/auth/repository/UserAuthRepository.java @@ -0,0 +1,35 @@ +package project.flipnote.auth.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import project.flipnote.auth.entity.AccountStatus; +import project.flipnote.auth.entity.UserAuth; + +public interface UserAuthRepository extends JpaRepository { + + boolean existsByEmail(String email); + + Optional findByEmailAndStatus(String email, AccountStatus status); + + boolean existsByEmailAndStatus(String email, AccountStatus status); + + @Modifying + @Query("UPDATE UserAuth aa SET aa.password = :password WHERE aa.email = :email") + void updatePassword(@Param("email") String email, @Param("password") String password); + + Optional findByIdAndStatus(Long authId, AccountStatus status); + + Optional findByUserIdAndStatus(Long userId, AccountStatus status); + + @Query("SELECT aa.tokenVersion FROM UserAuth aa WHERE aa.userId = :userId") + Optional findTokenVersionById(@Param("userId") Long userId); + + @Modifying + @Query("UPDATE UserAuth aa SET aa.tokenVersion = aa.tokenVersion + 1 WHERE aa.userId = :userId") + void incrementTokenVersion(@Param("userId") Long userId); +} diff --git a/src/main/java/project/flipnote/auth/service/AuthService.java b/src/main/java/project/flipnote/auth/service/AuthService.java index b8a48d0e..94e5147e 100644 --- a/src/main/java/project/flipnote/auth/service/AuthService.java +++ b/src/main/java/project/flipnote/auth/service/AuthService.java @@ -1,5 +1,6 @@ package project.flipnote.auth.service; +import java.util.List; import java.util.Objects; import org.springframework.context.ApplicationEventPublisher; @@ -10,34 +11,41 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import project.flipnote.auth.constants.VerificationConstants; +import project.flipnote.auth.entity.AccountStatus; +import project.flipnote.auth.entity.OAuthLink; +import project.flipnote.auth.entity.UserAuth; import project.flipnote.auth.event.EmailVerificationSendEvent; import project.flipnote.auth.event.PasswordResetCreateEvent; import project.flipnote.auth.exception.AuthErrorCode; +import project.flipnote.auth.model.ChangePasswordRequest; import project.flipnote.auth.model.EmailVerificationConfirmRequest; import project.flipnote.auth.model.EmailVerificationRequest; import project.flipnote.auth.model.PasswordResetCreateRequest; import project.flipnote.auth.model.PasswordResetRequest; import project.flipnote.auth.model.TokenPair; import project.flipnote.auth.model.UserLoginRequest; +import project.flipnote.auth.model.UserRegisterRequest; +import project.flipnote.auth.model.UserRegisterResponse; import project.flipnote.auth.repository.EmailVerificationRedisRepository; +import project.flipnote.auth.repository.OAuthLinkRepository; import project.flipnote.auth.repository.PasswordResetRedisRepository; import project.flipnote.auth.repository.TokenBlacklistRedisRepository; +import project.flipnote.auth.repository.UserAuthRepository; import project.flipnote.auth.util.PasswordResetTokenGenerator; import project.flipnote.auth.util.VerificationCodeGenerator; import project.flipnote.common.config.ClientProperties; +import project.flipnote.common.dto.UserCreateCommand; import project.flipnote.common.exception.BizException; -import project.flipnote.common.security.dto.UserAuth; +import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.common.security.jwt.JwtComponent; -import project.flipnote.user.entity.User; -import project.flipnote.user.entity.UserStatus; -import project.flipnote.user.repository.UserRepository; +import project.flipnote.user.model.SocialLinksResponse; +import project.flipnote.user.service.UserService; @Slf4j @RequiredArgsConstructor @Service public class AuthService { - private final UserRepository userRepository; private final JwtComponent jwtComponent; private final EmailVerificationRedisRepository emailVerificationRedisRepository; private final TokenBlacklistRedisRepository tokenBlacklistRedisRepository; @@ -47,19 +55,43 @@ public class AuthService { private final PasswordResetTokenGenerator passwordResetTokenGenerator; private final PasswordResetRedisRepository passwordResetRedisRepository; private final ClientProperties clientProperties; + private final UserService userService; + private final UserAuthRepository userAuthRepository; + private final TokenVersionService tokenVersionService; + private final OAuthLinkRepository oAuthLinkRepository; + + @Transactional + public UserRegisterResponse register(UserRegisterRequest req) { + String email = req.email(); + + validateEmailDuplicate(email); + validateEmailVerified(email); + + UserCreateCommand command = req.toCommand(); + Long userId = userService.createUser(command); + + UserAuth userAuth = UserAuth.builder() + .email(email) + .password(passwordEncoder.encode(req.password())) + .userId(userId) + .build(); + userAuthRepository.save(userAuth); + + return UserRegisterResponse.from(userId); + } public TokenPair login(UserLoginRequest req) { - User user = findActiveUserByEmail(req.email()); + UserAuth userAuth = findActiveAuthAccountByEmail(req.email()); - validatePasswordMatch(req.password(), user.getPassword()); + validatePasswordMatch(req.password(), userAuth.getPassword()); - return jwtComponent.generateTokenPair(user); + return jwtComponent.generateTokenPair(userAuth); } public void sendEmailVerificationCode(EmailVerificationRequest req) { String email = req.email(); - validateEmailIsAvailable(email); + validateEmailDuplicate(email); validateVerificationCodeNotExists(email); String code = verificationCodeGenerator.generateVerificationCode(VerificationConstants.CODE_LENGTH); @@ -88,7 +120,7 @@ public TokenPair refreshToken(String refreshToken) { long expirationMillis = jwtComponent.getExpirationMillis(refreshToken); tokenBlacklistRedisRepository.save(refreshToken, expirationMillis); - UserAuth userAuth = jwtComponent.extractUserAuthFromToken(refreshToken); + AuthPrinciple userAuth = jwtComponent.extractUserAuthFromToken(refreshToken); return jwtComponent.generateTokenPair(userAuth); } @@ -99,7 +131,7 @@ public void requestPasswordReset(PasswordResetCreateRequest req) { throw new BizException(AuthErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK); } - boolean existUser = userRepository.existsByEmailAndStatus(email, UserStatus.ACTIVE); + boolean existUser = userAuthRepository.existsByEmailAndStatus(email, AccountStatus.ACTIVE); if (existUser) { String token = passwordResetTokenGenerator.generateToken(); passwordResetRedisRepository.saveToken(email, token); @@ -117,24 +149,61 @@ public void resetPassword(PasswordResetRequest req) { .orElseThrow(() -> new BizException(AuthErrorCode.INVALID_PASSWORD_RESET_TOKEN)); String encodedPassword = passwordEncoder.encode(req.password()); - userRepository.updatePassword(email, encodedPassword); + userAuthRepository.updatePassword(email, encodedPassword); passwordResetRedisRepository.deleteToken(email, token); } + @Transactional + public void changePassword(Long authId, ChangePasswordRequest req) { + UserAuth userAuth = findActiveAuthAccount(authId); + + validatePasswordMatch(req.currentPassword(), userAuth.getPassword()); + + userAuth.changePassword(passwordEncoder.encode(req.newPassword())); + + tokenVersionService.incrementTokenVersion(authId); + } + + public SocialLinksResponse getSocialLinks(Long authId) { + List links = oAuthLinkRepository.findByUserAuth_Id(authId); + + return SocialLinksResponse.from(links); + } + + @Transactional + public void deleteSocialLink(Long authId, Long socialLinkId) { + if (!oAuthLinkRepository.existsByIdAndUserAuth_Id(socialLinkId, authId)) { + throw new BizException(AuthErrorCode.SOCIAL_LINK_NOT_FOUND); + } + + oAuthLinkRepository.deleteById(socialLinkId); + } + + private void validateEmailVerified(String email) { + if (!emailVerificationRedisRepository.isVerified(email)) { + throw new BizException(AuthErrorCode.UNVERIFIED_EMAIL); + } + } + public void validatePasswordMatch(String rawPassword, String encodedPassword) { if (!passwordEncoder.matches(rawPassword, encodedPassword)) { throw new BizException(AuthErrorCode.INVALID_CREDENTIALS); } } - private User findActiveUserByEmail(String email) { - return userRepository.findByEmailAndStatus(email, UserStatus.ACTIVE) + private UserAuth findActiveAuthAccountByEmail(String email) { + return userAuthRepository.findByEmailAndStatus(email, AccountStatus.ACTIVE) + .orElseThrow(() -> new BizException(AuthErrorCode.INVALID_CREDENTIALS)); + } + + private UserAuth findActiveAuthAccount(Long authId) { + return userAuthRepository.findByIdAndStatus(authId, AccountStatus.ACTIVE) .orElseThrow(() -> new BizException(AuthErrorCode.INVALID_CREDENTIALS)); } - private void validateEmailIsAvailable(String email) { - if (userRepository.existsByEmail(email)) { + private void validateEmailDuplicate(String email) { + if (userAuthRepository.existsByEmail(email)) { throw new BizException(AuthErrorCode.EXISTING_EMAIL); } } diff --git a/src/main/java/project/flipnote/auth/service/EmailVerificationService.java b/src/main/java/project/flipnote/auth/service/EmailVerificationService.java deleted file mode 100644 index fb6fa4ab..00000000 --- a/src/main/java/project/flipnote/auth/service/EmailVerificationService.java +++ /dev/null @@ -1,21 +0,0 @@ -package project.flipnote.auth.service; - -import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; -import project.flipnote.auth.exception.AuthErrorCode; -import project.flipnote.auth.repository.EmailVerificationRedisRepository; -import project.flipnote.common.exception.BizException; - -@RequiredArgsConstructor -@Service -public class EmailVerificationService { - - private final EmailVerificationRedisRepository emailVerificationRedisRepository; - - public void validateVerified(String email) { - if (!emailVerificationRedisRepository.isVerified(email)) { - throw new BizException(AuthErrorCode.UNVERIFIED_EMAIL); - } - } -} diff --git a/src/main/java/project/flipnote/auth/service/OAuthService.java b/src/main/java/project/flipnote/auth/service/OAuthService.java index ca2497c8..f12d173d 100644 --- a/src/main/java/project/flipnote/auth/service/OAuthService.java +++ b/src/main/java/project/flipnote/auth/service/OAuthService.java @@ -12,21 +12,21 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import project.flipnote.auth.constants.OAuthConstants; +import project.flipnote.auth.entity.OAuthLink; import project.flipnote.auth.exception.AuthErrorCode; import project.flipnote.auth.model.AuthorizationRedirect; import project.flipnote.auth.model.TokenPair; +import project.flipnote.auth.repository.OAuthLinkRepository; import project.flipnote.auth.repository.SocialLinkTokenRedisRepository; +import project.flipnote.auth.repository.UserAuthRepository; import project.flipnote.common.config.OAuthProperties; import project.flipnote.common.exception.BizException; -import project.flipnote.common.security.dto.UserAuth; +import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.common.security.jwt.JwtComponent; import project.flipnote.common.util.CookieUtil; import project.flipnote.common.util.PkceUtil; import project.flipnote.infra.oauth.OAuthApiClient; import project.flipnote.infra.oauth.model.OAuth2UserInfo; -import project.flipnote.user.entity.UserOAuthLink; -import project.flipnote.user.repository.UserOAuthLinkRepository; -import project.flipnote.user.repository.UserRepository; @Slf4j @RequiredArgsConstructor @@ -39,14 +39,14 @@ public class OAuthService { private final CookieUtil cookieUtil; private final OAuthApiClient oAuthApiClient; private final SocialLinkTokenRedisRepository socialLinkTokenRedisRepository; - private final UserRepository userRepository; - private final UserOAuthLinkRepository userOAuthLinkRepository; + private final OAuthLinkRepository userOAuthLinkRepository; private final JwtComponent jwtComponent; + private final UserAuthRepository userAuthRepository; public AuthorizationRedirect getAuthorizationUri( String providerName, HttpServletRequest request, - UserAuth userAuth + AuthPrinciple userAuth ) { OAuthProperties.Provider provider = getProvider(providerName); @@ -72,20 +72,20 @@ public void linkSocialAccount( String codeVerifier, HttpServletRequest request ) { - long userId = socialLinkTokenRedisRepository.findUserIdByToken(state) + long authId = socialLinkTokenRedisRepository.findAuthIdByToken(state) .orElseThrow(() -> new BizException(AuthErrorCode.INVALID_SOCIAL_LINK_TOKEN)); socialLinkTokenRedisRepository.deleteToken(state); OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request); - if (userOAuthLinkRepository.existsByUser_IdAndProviderId(userId, userInfo.getProviderId())) { + if (userOAuthLinkRepository.existsByUserAuth_IdAndProviderId(authId, userInfo.getProviderId())) { throw new BizException(AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT); } - UserOAuthLink userOAuthLink = new UserOAuthLink( + OAuthLink userOAuthLink = new OAuthLink( userInfo.getProvider(), userInfo.getProviderId(), - userRepository.getReferenceById(userId) + userAuthRepository.getReferenceById(authId) ); userOAuthLinkRepository.save(userOAuthLink); } @@ -93,11 +93,11 @@ public void linkSocialAccount( public TokenPair socialLogin(String providerName, String code, String codeVerifier, HttpServletRequest request) { OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request); - UserOAuthLink userOAuthLink = userOAuthLinkRepository.findByProviderAndProviderIdWithUser( + OAuthLink userOAuthLink = userOAuthLinkRepository.findByProviderAndProviderIdWithUserAuth( providerName, userInfo.getProviderId() ).orElseThrow(() -> new BizException(AuthErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT)); - return jwtComponent.generateTokenPair(userOAuthLink.getUser()); + return jwtComponent.generateTokenPair(userOAuthLink.getUserAuth()); } private OAuth2UserInfo getOAuth2UserInfo(String providerName, String code, String codeVerifier, @@ -116,12 +116,12 @@ private OAuthProperties.Provider getProvider(String providerName) { }); } - private String generateStateForSocialLink(UserAuth userAuth) { + private String generateStateForSocialLink(AuthPrinciple userAuth) { if (userAuth == null) { return null; } String state = UUID.randomUUID().toString(); - socialLinkTokenRedisRepository.saveToken(userAuth.userId(), state); + socialLinkTokenRedisRepository.saveToken(userAuth.authId(), state); return state; } } diff --git a/src/main/java/project/flipnote/auth/service/TokenVersionService.java b/src/main/java/project/flipnote/auth/service/TokenVersionService.java index d71d27b4..8669d896 100644 --- a/src/main/java/project/flipnote/auth/service/TokenVersionService.java +++ b/src/main/java/project/flipnote/auth/service/TokenVersionService.java @@ -3,31 +3,33 @@ import java.util.Optional; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import project.flipnote.auth.repository.TokenVersionRedisRepository; -import project.flipnote.user.repository.UserRepository; +import project.flipnote.auth.repository.UserAuthRepository; @RequiredArgsConstructor @Service public class TokenVersionService { private final TokenVersionRedisRepository tokenVersionRedisRepository; - private final UserRepository userRepository; + private final UserAuthRepository userAuthRepository; - public Optional findTokenVersion(long userId) { - return tokenVersionRedisRepository.getTokenVersion(userId) + public Optional findTokenVersion(long authId) { + return tokenVersionRedisRepository.getTokenVersion(authId) .or(() -> { - Optional dbTokenVersion = userRepository.findTokenVersionById(userId); + Optional dbTokenVersion = userAuthRepository.findTokenVersionById(authId); dbTokenVersion.ifPresent( - tokenVersion -> tokenVersionRedisRepository.saveTokenVersion(userId, tokenVersion) + tokenVersion -> tokenVersionRedisRepository.saveTokenVersion(authId, tokenVersion) ); return dbTokenVersion; }); } - public void incrementTokenVersion(long userId) { - userRepository.incrementTokenVersion(userId); - tokenVersionRedisRepository.deleteTokenVersion(userId); + @Transactional + public void incrementTokenVersion(long authId) { + userAuthRepository.incrementTokenVersion(authId); + tokenVersionRedisRepository.deleteTokenVersion(authId); } } diff --git a/src/main/java/project/flipnote/common/config/OAuthProperties.java b/src/main/java/project/flipnote/common/config/OAuthProperties.java index e539ff2f..bc67f7dc 100644 --- a/src/main/java/project/flipnote/common/config/OAuthProperties.java +++ b/src/main/java/project/flipnote/common/config/OAuthProperties.java @@ -46,4 +46,4 @@ public static class Provider { @NotNull private List scope; } -} \ No newline at end of file +} diff --git a/src/main/java/project/flipnote/common/config/SwaggerConfig.java b/src/main/java/project/flipnote/common/config/SwaggerConfig.java index edeb500e..57e1f18d 100644 --- a/src/main/java/project/flipnote/common/config/SwaggerConfig.java +++ b/src/main/java/project/flipnote/common/config/SwaggerConfig.java @@ -19,7 +19,6 @@ public OpenAPI openApi() { .addSecurityItem( new SecurityRequirement() .addList("access-token") - .addList("refresh-token-cookie") ) .components(new Components() .addSecuritySchemes("access-token", @@ -27,12 +26,7 @@ public OpenAPI openApi() { .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT")) - .addSecuritySchemes("refresh-token-cookie", - new SecurityScheme() - .type(SecurityScheme.Type.APIKEY) - .in(SecurityScheme.In.COOKIE) - .name(JwtConstants.REFRESH_TOKEN) - .description("refreshToken 쿠키에 JWT 값을 담아주세요."))) + ) .info(apiInfo()); } diff --git a/src/main/java/project/flipnote/common/config/WebConfig.java b/src/main/java/project/flipnote/common/config/WebConfig.java index ae1e1f50..e3fad919 100644 --- a/src/main/java/project/flipnote/common/config/WebConfig.java +++ b/src/main/java/project/flipnote/common/config/WebConfig.java @@ -18,4 +18,4 @@ public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(oAuthCookieCleanupInterceptor) .addPathPatterns("/oauth2/callback/**"); } -} \ No newline at end of file +} diff --git a/src/main/java/project/flipnote/common/dto/UserCreateCommand.java b/src/main/java/project/flipnote/common/dto/UserCreateCommand.java new file mode 100644 index 00000000..67aac2aa --- /dev/null +++ b/src/main/java/project/flipnote/common/dto/UserCreateCommand.java @@ -0,0 +1,12 @@ +package project.flipnote.common.dto; + +public record UserCreateCommand( + String email, + String name, + String nickname, + Boolean smsAgree, + String phone, + String profileImageUrl +) { + +} diff --git a/src/main/java/project/flipnote/common/event/UserWithdrawnEvent.java b/src/main/java/project/flipnote/common/event/UserWithdrawnEvent.java new file mode 100644 index 00000000..8fdefd62 --- /dev/null +++ b/src/main/java/project/flipnote/common/event/UserWithdrawnEvent.java @@ -0,0 +1,6 @@ +package project.flipnote.common.event; + +public record UserWithdrawnEvent( + Long userId +) { +} diff --git a/src/main/java/project/flipnote/common/security/config/SecurityConfig.java b/src/main/java/project/flipnote/common/security/config/SecurityConfig.java index 6b947cec..4fa08b8a 100644 --- a/src/main/java/project/flipnote/common/security/config/SecurityConfig.java +++ b/src/main/java/project/flipnote/common/security/config/SecurityConfig.java @@ -58,7 +58,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers( HttpMethod.POST, - "/*/users", "/*/auth/token/refresh", "/*/auth/password-resets" + "/*/users", "/*/auth/token/refresh", "/*/auth/password-resets", "/*/auth/register" ).permitAll() .requestMatchers(HttpMethod.PATCH, "/*/auth/password-resets").permitAll() .requestMatchers( diff --git a/src/main/java/project/flipnote/common/security/dto/AuthPrinciple.java b/src/main/java/project/flipnote/common/security/dto/AuthPrinciple.java new file mode 100644 index 00000000..db5239ac --- /dev/null +++ b/src/main/java/project/flipnote/common/security/dto/AuthPrinciple.java @@ -0,0 +1,42 @@ +package project.flipnote.common.security.dto; + +import java.util.Collection; +import java.util.List; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import io.jsonwebtoken.Claims; +import project.flipnote.auth.entity.AccountRole; +import project.flipnote.common.security.jwt.JwtConstants; + +public record AuthPrinciple( + Long authId, + Long userId, + String email, + AccountRole role, + long tokenVersion +) { + + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); + } + + public static AuthPrinciple from(project.flipnote.auth.entity.UserAuth account) { + return new AuthPrinciple( + account.getId(), account.getUserId(), account.getEmail(), account.getRole(), account.getTokenVersion() + ); + } + + public static AuthPrinciple from(Claims claims) { + long authId = Long.parseLong(claims.getId()); + long userId = claims.get(JwtConstants.USER_ID, Long.class); + AccountRole userRole = AccountRole.from( + claims.get(JwtConstants.ROLE, String.class) + ); + String email = claims.getSubject(); + long tokenVersion = claims.get(JwtConstants.TOKEN_VERSION, Long.class); + + return new AuthPrinciple(authId, userId, email, userRole, tokenVersion); + } +} diff --git a/src/main/java/project/flipnote/common/security/dto/UserAuth.java b/src/main/java/project/flipnote/common/security/dto/UserAuth.java deleted file mode 100644 index 533e0dc5..00000000 --- a/src/main/java/project/flipnote/common/security/dto/UserAuth.java +++ /dev/null @@ -1,39 +0,0 @@ -package project.flipnote.common.security.dto; - -import java.util.Collection; -import java.util.List; - -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; - -import io.jsonwebtoken.Claims; -import project.flipnote.common.security.jwt.JwtConstants; -import project.flipnote.user.entity.User; -import project.flipnote.user.entity.UserRole; - -public record UserAuth( - Long userId, - String email, - UserRole userRole, - long tokenVersion -) { - - public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority("ROLE_" + userRole.name())); - } - - public static UserAuth from(User user) { - return new UserAuth(user.getId(), user.getEmail(), user.getRole(), user.getTokenVersion()); - } - - public static UserAuth from(Claims claims) { - long userId = Long.parseLong(claims.getId()); - UserRole userRole = UserRole.from( - claims.get(JwtConstants.ROLE, String.class) - ); - String email = claims.getSubject(); - long tokenVersion = claims.get(JwtConstants.TOKEN_VERSION, Long.class); - - return new UserAuth(userId, email, userRole, tokenVersion); - } -} diff --git a/src/main/java/project/flipnote/common/security/filter/JwtAuthenticationFilter.java b/src/main/java/project/flipnote/common/security/filter/JwtAuthenticationFilter.java index 07c53cf8..c4d92b8a 100644 --- a/src/main/java/project/flipnote/common/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/project/flipnote/common/security/filter/JwtAuthenticationFilter.java @@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import project.flipnote.common.security.dto.UserAuth; +import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.common.security.jwt.JwtComponent; import project.flipnote.common.security.jwt.JwtConstants; @@ -35,7 +35,7 @@ protected void doFilterInternal( String token = extractToken(request); if (StringUtils.hasText(token)) { - UserAuth userAuth = jwtComponent.extractUserAuthFromToken(token); + AuthPrinciple userAuth = jwtComponent.extractUserAuthFromToken(token); if (userAuth != null) { setAuthentication(userAuth, token, request); } @@ -52,7 +52,7 @@ private String extractToken(HttpServletRequest request) { return null; } - private void setAuthentication(UserAuth userAuth, String token, HttpServletRequest request) { + private void setAuthentication(AuthPrinciple userAuth, String token, HttpServletRequest request) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userAuth, token, userAuth.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); diff --git a/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java b/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java index 225db510..a67932f7 100644 --- a/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java +++ b/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java @@ -12,12 +12,12 @@ import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import project.flipnote.auth.entity.UserAuth; import project.flipnote.auth.model.TokenPair; import project.flipnote.auth.service.TokenVersionService; -import project.flipnote.common.security.dto.UserAuth; -import project.flipnote.common.security.exception.SecurityErrorCode; +import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.common.security.exception.CustomSecurityException; -import project.flipnote.user.entity.User; +import project.flipnote.common.security.exception.SecurityErrorCode; @RequiredArgsConstructor @Component @@ -32,39 +32,40 @@ public void init() { this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes()); } - public TokenPair generateTokenPair(User user) { - UserAuth userAuth = UserAuth.from(user); + public TokenPair generateTokenPair(UserAuth userAuth) { + AuthPrinciple authPrinciple = AuthPrinciple.from(userAuth); - return generateTokenPair(userAuth); + return generateTokenPair(authPrinciple); } - public TokenPair generateTokenPair(UserAuth userAuth) { - String accessToken = generateAccessToken(userAuth); - String refreshToken = generateRefreshToken(userAuth); + public TokenPair generateTokenPair(AuthPrinciple authPrinciple) { + String accessToken = generateAccessToken(authPrinciple); + String refreshToken = generateRefreshToken(authPrinciple); return TokenPair.from(accessToken, refreshToken); } - private String generateAccessToken(UserAuth userAuth) { + private String generateAccessToken(AuthPrinciple userAuth) { return generateToken( userAuth, jwtProperties.getAccessTokenExpiredDate(new Date()) ); } - private String generateRefreshToken(UserAuth userAuth) { + private String generateRefreshToken(AuthPrinciple userAuth) { return generateToken( userAuth, jwtProperties.getRefreshTokenExpiredDate(new Date()) ); } - private String generateToken(UserAuth userAuth, Date expiration) { + private String generateToken(AuthPrinciple userAuth, Date expiration) { Date now = new Date(); return Jwts.builder() .subject(userAuth.email()) - .id(String.valueOf(userAuth.userId())) - .claim(JwtConstants.ROLE, userAuth.userRole().name()) + .id(String.valueOf(userAuth.authId())) + .claim(JwtConstants.USER_ID, userAuth.userId()) + .claim(JwtConstants.ROLE, userAuth.role().name()) .claim(JwtConstants.TOKEN_VERSION, userAuth.tokenVersion()) .issuedAt(now) .expiration(expiration) @@ -72,9 +73,9 @@ private String generateToken(UserAuth userAuth, Date expiration) { .compact(); } - public UserAuth extractUserAuthFromToken(String token) { + public AuthPrinciple extractUserAuthFromToken(String token) { Claims claims = parseClaims(token); - UserAuth userAuth = UserAuth.from(claims); + AuthPrinciple userAuth = AuthPrinciple.from(claims); validateToken(userAuth); return userAuth; @@ -105,8 +106,8 @@ private Claims parseClaims(String token) { } } - private void validateToken(UserAuth userAuth) { - long currentTokenVersion = tokenVersionService.findTokenVersion(userAuth.userId()) + private void validateToken(AuthPrinciple userAuth) { + long currentTokenVersion = tokenVersionService.findTokenVersion(userAuth.authId()) .orElseThrow(() -> new CustomSecurityException(SecurityErrorCode.NOT_VALID_JWT_TOKEN)); if (userAuth.tokenVersion() != currentTokenVersion) { diff --git a/src/main/java/project/flipnote/common/security/jwt/JwtConstants.java b/src/main/java/project/flipnote/common/security/jwt/JwtConstants.java index 9158223f..5c91815d 100644 --- a/src/main/java/project/flipnote/common/security/jwt/JwtConstants.java +++ b/src/main/java/project/flipnote/common/security/jwt/JwtConstants.java @@ -10,6 +10,7 @@ public final class JwtConstants { public static final String ROLE = "role"; public static final String TOKEN_VERSION = "token_version"; + public static final String USER_ID = "user_id"; public static final String AUTH_HEADER = "Authorization"; public static final String TOKEN_PREFIX = "Bearer "; diff --git a/src/main/java/project/flipnote/common/util/PkceUtil.java b/src/main/java/project/flipnote/common/util/PkceUtil.java index 13b4cccb..dadd9b9e 100644 --- a/src/main/java/project/flipnote/common/util/PkceUtil.java +++ b/src/main/java/project/flipnote/common/util/PkceUtil.java @@ -28,4 +28,4 @@ public String generateCodeChallenge(String codeVerifier) { throw new RuntimeException("SHA-256 algorithm not found", e); } } -} \ No newline at end of file +} diff --git a/src/main/java/project/flipnote/group/controller/GroupController.java b/src/main/java/project/flipnote/group/controller/GroupController.java index 3d65dbae..e41b2c54 100644 --- a/src/main/java/project/flipnote/group/controller/GroupController.java +++ b/src/main/java/project/flipnote/group/controller/GroupController.java @@ -10,7 +10,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import project.flipnote.common.security.dto.UserAuth; +import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.group.model.GroupCreateRequest; import project.flipnote.group.model.GroupCreateResponse; import project.flipnote.group.service.GroupService; @@ -23,7 +23,7 @@ public class GroupController { @PostMapping("") public ResponseEntity create( - @AuthenticationPrincipal UserAuth userAuth, + @AuthenticationPrincipal AuthPrinciple userAuth, @Valid @RequestBody GroupCreateRequest req) { GroupCreateResponse res = groupService.create(userAuth, req); return ResponseEntity.status(HttpStatus.CREATED).body(res); diff --git a/src/main/java/project/flipnote/group/entity/Group.java b/src/main/java/project/flipnote/group/entity/Group.java index 3ab75e7c..d5625a7e 100644 --- a/src/main/java/project/flipnote/group/entity/Group.java +++ b/src/main/java/project/flipnote/group/entity/Group.java @@ -1,10 +1,5 @@ package project.flipnote.group.entity; - -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -13,6 +8,9 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -60,7 +58,7 @@ private Group( Boolean publicVisible, Integer maxMember, String imageUrl - ) { + ) { this.name = name; this.category = category; this.description = description; diff --git a/src/main/java/project/flipnote/group/entity/GroupMember.java b/src/main/java/project/flipnote/group/entity/GroupMember.java index 6df36edd..0d41b3ea 100644 --- a/src/main/java/project/flipnote/group/entity/GroupMember.java +++ b/src/main/java/project/flipnote/group/entity/GroupMember.java @@ -1,10 +1,5 @@ package project.flipnote.group.entity; -import java.time.LocalDateTime; - -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -16,12 +11,11 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import project.flipnote.common.entity.BaseEntity; -import project.flipnote.user.entity.User; +import project.flipnote.user.entity.UserProfile; @Getter @Entity @@ -38,7 +32,7 @@ public class GroupMember extends BaseEntity { @ManyToOne @JoinColumn(name = "user_id", nullable = false) - private User user; + private UserProfile user; //기본 값은 MEMBER; @Enumerated(EnumType.STRING) @@ -46,7 +40,7 @@ public class GroupMember extends BaseEntity { private GroupMemberRole role = GroupMemberRole.MEMBER; @Builder - private GroupMember(Group group, User user, GroupMemberRole role) { + private GroupMember(Group group, UserProfile user, GroupMemberRole role) { this.group = group; this.user = user; this.role = role != null ? role : GroupMemberRole.MEMBER; diff --git a/src/main/java/project/flipnote/group/service/GroupService.java b/src/main/java/project/flipnote/group/service/GroupService.java index 8d19559d..b00ff005 100644 --- a/src/main/java/project/flipnote/group/service/GroupService.java +++ b/src/main/java/project/flipnote/group/service/GroupService.java @@ -9,7 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import project.flipnote.common.exception.BizException; -import project.flipnote.common.security.dto.UserAuth; +import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.group.entity.Group; import project.flipnote.group.entity.GroupMember; import project.flipnote.group.entity.GroupMemberRole; @@ -23,10 +23,9 @@ import project.flipnote.group.repository.GroupPermissionRepository; import project.flipnote.group.repository.GroupRepository; import project.flipnote.group.repository.GroupRolePermissionRepository; -import project.flipnote.user.entity.User; -import project.flipnote.user.entity.UserStatus; +import project.flipnote.user.entity.UserProfile; import project.flipnote.user.exception.UserErrorCode; -import project.flipnote.user.repository.UserRepository; +import project.flipnote.user.repository.UserProfileRepository; @Slf4j @Service @@ -38,21 +37,21 @@ public class GroupService { private final GroupMemberRepository groupMemberRepository; private final GroupPermissionRepository groupPermissionRepository; private final GroupRolePermissionRepository groupRolePermissionRepository; - private final UserRepository userRepository; + private final UserProfileRepository userProfileRepository; //유저 정보 조회 - public User findUser(UserAuth userAuth) { - return userRepository.findByIdAndStatus(userAuth.userId(), UserStatus.ACTIVE).orElseThrow( + public UserProfile findUser(AuthPrinciple userAuth) { + return userProfileRepository.findById(userAuth.userId()).orElseThrow( () -> new BizException(UserErrorCode.USER_NOT_FOUND) ); } //그룹 생성 @Transactional - public GroupCreateResponse create(UserAuth userAuth, GroupCreateRequest req) { + public GroupCreateResponse create(AuthPrinciple userAuth, GroupCreateRequest req) { //1. 유저 조회 - User user = findUser(userAuth); + UserProfile user = findUser(userAuth); //2. 인원수 검증 validateMaxMember(req.maxMember()); @@ -111,7 +110,7 @@ private Group createGroup(GroupCreateRequest req) { /* 그룹 생성시 오너 멤버 추가 */ - private void saveGroupOwner(Group group, User user) { + private void saveGroupOwner(Group group, UserProfile user) { GroupMember groupMember = GroupMember.builder() .group(group) .user(user) diff --git a/src/main/java/project/flipnote/infra/email/EmailService.java b/src/main/java/project/flipnote/infra/email/EmailService.java index 0e8c2376..c2cf3f6f 100644 --- a/src/main/java/project/flipnote/infra/email/EmailService.java +++ b/src/main/java/project/flipnote/infra/email/EmailService.java @@ -3,5 +3,6 @@ public interface EmailService { void sendEmailVerificationCode(String to, String code, int ttl); + void sendPasswordResetLink(String to, String link, int ttl); } diff --git a/src/main/java/project/flipnote/infra/oauth/OAuthApiClient.java b/src/main/java/project/flipnote/infra/oauth/OAuthApiClient.java index 3261c1d6..841d697a 100644 --- a/src/main/java/project/flipnote/infra/oauth/OAuthApiClient.java +++ b/src/main/java/project/flipnote/infra/oauth/OAuthApiClient.java @@ -27,7 +27,12 @@ public class OAuthApiClient { private final RestClient restClient; private final ObjectMapper objectMapper; - public String requestAccessToken(OAuthProperties.Provider provider, String code, String codeVerifier, HttpServletRequest request) { + public String requestAccessToken( + OAuthProperties.Provider provider, + String code, + String codeVerifier, + HttpServletRequest request + ) { MultiValueMap params = new LinkedMultiValueMap<>(); params.add("grant_type", "authorization_code"); params.add("client_id", provider.getClientId()); @@ -44,8 +49,9 @@ public String requestAccessToken(OAuthProperties.Provider provider, String code, .retrieve() .body(String.class); - Map responseMap = objectMapper.readValue(responseBody, new TypeReference<>() {}); - return (String) responseMap.get("access_token"); + Map responseMap = objectMapper.readValue(responseBody, new TypeReference<>() { + }); + return (String)responseMap.get("access_token"); } catch (Exception e) { throw new RuntimeException("Failed to get Access Token", e); } @@ -59,7 +65,8 @@ public Map requestUserInfo(OAuthProperties.Provider provider, St .retrieve() .body(String.class); - return objectMapper.readValue(responseBody, new TypeReference<>() {}); + return objectMapper.readValue(responseBody, new TypeReference<>() { + }); } catch (Exception e) { throw new RuntimeException("Failed to get User Info", e); } diff --git a/src/main/java/project/flipnote/infra/oauth/model/OAuth2UserInfo.java b/src/main/java/project/flipnote/infra/oauth/model/OAuth2UserInfo.java index c02e26bb..8fea2e23 100644 --- a/src/main/java/project/flipnote/infra/oauth/model/OAuth2UserInfo.java +++ b/src/main/java/project/flipnote/infra/oauth/model/OAuth2UserInfo.java @@ -2,7 +2,10 @@ public interface OAuth2UserInfo { String getProviderId(); + String getProvider(); + String getEmail(); + String getName(); } diff --git a/src/main/java/project/flipnote/user/controller/UserController.java b/src/main/java/project/flipnote/user/controller/UserController.java index f9f7f6bc..d04c97fd 100644 --- a/src/main/java/project/flipnote/user/controller/UserController.java +++ b/src/main/java/project/flipnote/user/controller/UserController.java @@ -1,13 +1,10 @@ package project.flipnote.user.controller; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -15,13 +12,10 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import project.flipnote.common.security.dto.UserAuth; +import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.user.controller.docs.UserControllerDocs; import project.flipnote.user.model.MyInfoResponse; -import project.flipnote.user.model.ChangePasswordRequest; -import project.flipnote.user.model.SocialLinksResponse; import project.flipnote.user.model.UserInfoResponse; -import project.flipnote.user.model.UserRegisterRequest; -import project.flipnote.user.model.UserRegisterResponse; import project.flipnote.user.model.UserUpdateRequest; import project.flipnote.user.model.UserUpdateResponse; import project.flipnote.user.service.UserService; @@ -29,25 +23,19 @@ @RequiredArgsConstructor @RestController @RequestMapping("/v1/users") -public class UserController { +public class UserController implements UserControllerDocs { private final UserService userService; - @PostMapping - public ResponseEntity register(@Valid @RequestBody UserRegisterRequest req) { - UserRegisterResponse res = userService.register(req); - return ResponseEntity.status(HttpStatus.CREATED).body(res); - } - @DeleteMapping - public ResponseEntity unregister(@AuthenticationPrincipal UserAuth userAuth) { - userService.unregister(userAuth.userId()); + public ResponseEntity withdraw(@AuthenticationPrincipal AuthPrinciple userAuth) { + userService.withdraw(userAuth.userId()); return ResponseEntity.noContent().build(); } @PutMapping public ResponseEntity update( - @AuthenticationPrincipal UserAuth userAuth, + @AuthenticationPrincipal AuthPrinciple userAuth, @Valid @RequestBody UserUpdateRequest req ) { UserUpdateResponse res = userService.update(userAuth.userId(), req); @@ -56,7 +44,7 @@ public ResponseEntity update( @GetMapping("/me") public ResponseEntity getMyInfo( - @AuthenticationPrincipal UserAuth userAuth + @AuthenticationPrincipal AuthPrinciple userAuth ) { MyInfoResponse res = userService.getMyInfo(userAuth.userId()); return ResponseEntity.ok(res); @@ -69,32 +57,4 @@ public ResponseEntity getUserInfo( UserInfoResponse res = userService.getUserInfo(userId); return ResponseEntity.ok(res); } - - @PatchMapping("/me/password") - public ResponseEntity updatePassword( - @AuthenticationPrincipal UserAuth userAuth, - @Valid @RequestBody ChangePasswordRequest req - ) { - userService.changePassword(userAuth.userId(), req); - return ResponseEntity.noContent().build(); - } - - @GetMapping("/me/social-links") - public ResponseEntity getSocialLinks( - @AuthenticationPrincipal UserAuth userAuth - ) { - SocialLinksResponse res = userService.getSocialLinks(userAuth.userId()); - - return ResponseEntity.ok(res); - } - - @DeleteMapping("/me/social-links/{socialLinkId}") - public ResponseEntity deleteSocialLink( - @AuthenticationPrincipal UserAuth userAuth, - @PathVariable("socialLinkId") Long socialLinkId - ) { - userService.deleteSocialLink(userAuth.userId(), socialLinkId); - - return ResponseEntity.noContent().build(); - } } diff --git a/src/main/java/project/flipnote/user/controller/docs/UserControllerDocs.java b/src/main/java/project/flipnote/user/controller/docs/UserControllerDocs.java new file mode 100644 index 00000000..6cdf3374 --- /dev/null +++ b/src/main/java/project/flipnote/user/controller/docs/UserControllerDocs.java @@ -0,0 +1,28 @@ +package project.flipnote.user.controller.docs; + +import org.springframework.http.ResponseEntity; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.user.model.MyInfoResponse; +import project.flipnote.user.model.UserInfoResponse; +import project.flipnote.user.model.UserUpdateRequest; +import project.flipnote.user.model.UserUpdateResponse; + +@Tag(name = "User", description = "User API") +public interface UserControllerDocs { + + @Operation(summary = "회원 탈퇴", security = { @SecurityRequirement(name = "access-token") }) + ResponseEntity withdraw(AuthPrinciple userAuth); + + @Operation(summary = "회원 정보 수정", security = { @SecurityRequirement(name = "access-token") }) + ResponseEntity update(AuthPrinciple userAuth, UserUpdateRequest req); + + @Operation(summary = "내 정보 조회", security = { @SecurityRequirement(name = "access-token") }) + ResponseEntity getMyInfo(AuthPrinciple userAuth); + + @Operation(summary = "회원 정보 조회", security = { @SecurityRequirement(name = "access-token") }) + ResponseEntity getUserInfo(Long userId); +} diff --git a/src/main/java/project/flipnote/user/entity/User.java b/src/main/java/project/flipnote/user/entity/UserProfile.java similarity index 73% rename from src/main/java/project/flipnote/user/entity/User.java rename to src/main/java/project/flipnote/user/entity/UserProfile.java index 43c3cdcf..d7e36316 100644 --- a/src/main/java/project/flipnote/user/entity/User.java +++ b/src/main/java/project/flipnote/user/entity/UserProfile.java @@ -14,15 +14,16 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import project.flipnote.auth.entity.AccountRole; +import project.flipnote.auth.entity.AccountStatus; import project.flipnote.common.crypto.AesCryptoConverter; import project.flipnote.common.entity.SoftDeletableEntity; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Table(name = "users") +@Table(name = "user_profiles") @Entity -public class User extends SoftDeletableEntity { +public class UserProfile extends SoftDeletableEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -31,8 +32,6 @@ public class User extends SoftDeletableEntity { @Column(unique = true, nullable = false) private String email; - private String password; - @Column(nullable = false) private String name; @@ -51,17 +50,9 @@ public class User extends SoftDeletableEntity { @Column(nullable = false) private UserStatus status; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private UserRole role; - - @Column(nullable = false) - private long tokenVersion; - @Builder - public User( + public UserProfile( String email, - String password, String name, String nickname, String profileImageUrl, @@ -69,27 +60,12 @@ public User( boolean smsAgree ) { this.email = email; - this.password = password; this.name = name; this.nickname = nickname; this.profileImageUrl = profileImageUrl; this.phone = phone; this.smsAgree = smsAgree; this.status = UserStatus.ACTIVE; - this.role = UserRole.USER; - this.tokenVersion = 0L; - } - - public void unregister() { - super.softDelete(); - - this.status = UserStatus.INACTIVE; - - increaseTokenVersion(); - } - - public void increaseTokenVersion() { - this.tokenVersion++; } public void update(String nickname, String phone, boolean smsAgree, String profileImageUrl) { @@ -99,7 +75,9 @@ public void update(String nickname, String phone, boolean smsAgree, String profi this.profileImageUrl = profileImageUrl; } - public void changePassword(String password) { - this.password = password; + public void withdraw() { + softDelete(); + + this.status = UserStatus.WITHDRAWN; } } diff --git a/src/main/java/project/flipnote/user/entity/UserRole.java b/src/main/java/project/flipnote/user/entity/UserRole.java deleted file mode 100644 index 10e94ba0..00000000 --- a/src/main/java/project/flipnote/user/entity/UserRole.java +++ /dev/null @@ -1,14 +0,0 @@ -package project.flipnote.user.entity; - -import java.util.Arrays; - -public enum UserRole { - USER, ADMIN; - - public static UserRole from(String role) { - return Arrays.stream(UserRole.values()) - .filter(r -> r.name().equalsIgnoreCase(role)) - .findFirst() - .orElse(null); - } -} diff --git a/src/main/java/project/flipnote/user/entity/UserStatus.java b/src/main/java/project/flipnote/user/entity/UserStatus.java index aa89dfdd..99f52604 100644 --- a/src/main/java/project/flipnote/user/entity/UserStatus.java +++ b/src/main/java/project/flipnote/user/entity/UserStatus.java @@ -4,5 +4,5 @@ @Getter public enum UserStatus { - ACTIVE, INACTIVE; + ACTIVE, WITHDRAWN; } diff --git a/src/main/java/project/flipnote/user/exception/UserErrorCode.java b/src/main/java/project/flipnote/user/exception/UserErrorCode.java index e3e8d28e..360076fe 100644 --- a/src/main/java/project/flipnote/user/exception/UserErrorCode.java +++ b/src/main/java/project/flipnote/user/exception/UserErrorCode.java @@ -12,9 +12,7 @@ public enum UserErrorCode implements ErrorCode { DUPLICATE_EMAIL(HttpStatus.CONFLICT, "USER_001", "이미 사용 중인 이메일입니다."), DUPLICATE_PHONE(HttpStatus.CONFLICT, "USER_002", "이미 사용 중인 휴대전화 번호입니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_003", "회원이 존재하지 않습니다."), - SOCIAL_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_004", "소셜 연동 계정이 존재하지 않습니다."); - + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_003", "회원이 존재하지 않습니다."); private final HttpStatus httpStatus; private final String code; private final String message; diff --git a/src/main/java/project/flipnote/user/model/MyInfoResponse.java b/src/main/java/project/flipnote/user/model/MyInfoResponse.java index ff6dfd83..37c52138 100644 --- a/src/main/java/project/flipnote/user/model/MyInfoResponse.java +++ b/src/main/java/project/flipnote/user/model/MyInfoResponse.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonFormat; -import project.flipnote.user.entity.User; +import project.flipnote.user.entity.UserProfile; public record MyInfoResponse( Long userId, @@ -22,7 +22,7 @@ public record MyInfoResponse( LocalDateTime modifiedAt ) { - public static MyInfoResponse from(User user) { + public static MyInfoResponse from(UserProfile user) { return new MyInfoResponse( user.getId(), user.getEmail(), diff --git a/src/main/java/project/flipnote/user/model/SocialLinkResponse.java b/src/main/java/project/flipnote/user/model/SocialLinkResponse.java index 5c70894b..aebca4b6 100644 --- a/src/main/java/project/flipnote/user/model/SocialLinkResponse.java +++ b/src/main/java/project/flipnote/user/model/SocialLinkResponse.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonFormat; -import project.flipnote.user.entity.UserOAuthLink; +import project.flipnote.auth.entity.OAuthLink; public record SocialLinkResponse( @@ -16,7 +16,7 @@ public record SocialLinkResponse( LocalDateTime linkedAt ) { - public static SocialLinkResponse from(UserOAuthLink link) { + public static SocialLinkResponse from(OAuthLink link) { return new SocialLinkResponse( link.getId(), link.getProvider(), diff --git a/src/main/java/project/flipnote/user/model/SocialLinksResponse.java b/src/main/java/project/flipnote/user/model/SocialLinksResponse.java index 593219d5..ad0be7ce 100644 --- a/src/main/java/project/flipnote/user/model/SocialLinksResponse.java +++ b/src/main/java/project/flipnote/user/model/SocialLinksResponse.java @@ -2,13 +2,13 @@ import java.util.List; -import project.flipnote.user.entity.UserOAuthLink; +import project.flipnote.auth.entity.OAuthLink; public record SocialLinksResponse( List socialLinks ) { - public static SocialLinksResponse from(List links) { + public static SocialLinksResponse from(List links) { List socialLinks = links.stream() .map(SocialLinkResponse::from) .toList(); diff --git a/src/main/java/project/flipnote/user/model/UserInfoResponse.java b/src/main/java/project/flipnote/user/model/UserInfoResponse.java index 291c3938..0006d46a 100644 --- a/src/main/java/project/flipnote/user/model/UserInfoResponse.java +++ b/src/main/java/project/flipnote/user/model/UserInfoResponse.java @@ -1,6 +1,6 @@ package project.flipnote.user.model; -import project.flipnote.user.entity.User; +import project.flipnote.user.entity.UserProfile; public record UserInfoResponse( Long userId, @@ -8,7 +8,7 @@ public record UserInfoResponse( String profileImageUrl ) { - public static UserInfoResponse from(User user) { + public static UserInfoResponse from(UserProfile user) { return new UserInfoResponse(user.getId(), user.getNickname(), user.getProfileImageUrl()); } } diff --git a/src/main/java/project/flipnote/user/model/UserUpdateResponse.java b/src/main/java/project/flipnote/user/model/UserUpdateResponse.java index be29cdc4..24d87636 100644 --- a/src/main/java/project/flipnote/user/model/UserUpdateResponse.java +++ b/src/main/java/project/flipnote/user/model/UserUpdateResponse.java @@ -1,6 +1,6 @@ package project.flipnote.user.model; -import project.flipnote.user.entity.User; +import project.flipnote.user.entity.UserProfile; public record UserUpdateResponse( Long userId, @@ -10,7 +10,7 @@ public record UserUpdateResponse( String profileImageUrl ) { - public static UserUpdateResponse from(User user) { + public static UserUpdateResponse from(UserProfile user) { return new UserUpdateResponse( user.getId(), user.getNickname(), user.getPhone(), user.isSmsAgree(), user.getProfileImageUrl() ); diff --git a/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java b/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java deleted file mode 100644 index c1e61275..00000000 --- a/src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java +++ /dev/null @@ -1,32 +0,0 @@ -package project.flipnote.user.repository; - -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import project.flipnote.user.entity.UserOAuthLink; - -public interface UserOAuthLinkRepository extends JpaRepository { - - boolean existsByUser_IdAndProviderId(Long userId, String providerId); - - List findByUser_Id(Long userId); - - boolean existsByIdAndUser_Id(Long id, Long userId); - - @Query(""" - SELECT uol - FROM UserOAuthLink uol - JOIN FETCH uol.user - WHERE uol.provider = :provider - AND uol.providerId = :providerId - """) - Optional findByProviderAndProviderIdWithUser( - @Param("provider") String provider, - @Param("providerId") String providerId - ); - -} diff --git a/src/main/java/project/flipnote/user/repository/UserProfileRepository.java b/src/main/java/project/flipnote/user/repository/UserProfileRepository.java new file mode 100644 index 00000000..19eb4141 --- /dev/null +++ b/src/main/java/project/flipnote/user/repository/UserProfileRepository.java @@ -0,0 +1,17 @@ +package project.flipnote.user.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import project.flipnote.user.entity.UserProfile; +import project.flipnote.user.entity.UserStatus; + +public interface UserProfileRepository extends JpaRepository { + + boolean existsByEmail(String email); + + boolean existsByPhone(String phone); + + Optional findByIdAndStatus(Long userId, UserStatus status); +} diff --git a/src/main/java/project/flipnote/user/repository/UserRepository.java b/src/main/java/project/flipnote/user/repository/UserRepository.java deleted file mode 100644 index f3b0f1ce..00000000 --- a/src/main/java/project/flipnote/user/repository/UserRepository.java +++ /dev/null @@ -1,35 +0,0 @@ -package project.flipnote.user.repository; - -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import project.flipnote.user.entity.User; -import project.flipnote.user.entity.UserStatus; - -public interface UserRepository extends JpaRepository { - - boolean existsByEmail(String email); - - boolean existsByPhone(String phone); - - Optional findByIdAndStatus(Long id, UserStatus userStatus); - - Optional findByEmailAndStatus(String email, UserStatus status); - - @Query("SELECT u.tokenVersion FROM User u WHERE u.id = :userId") - Optional findTokenVersionById(@Param("userId") Long userId); - - @Modifying - @Query("UPDATE User u SET u.tokenVersion = u.tokenVersion + 1 WHERE u.id = :userId") - void incrementTokenVersion(@Param("userId") Long userId); - - boolean existsByEmailAndStatus(String email, UserStatus status); - - @Modifying - @Query("UPDATE User u SET u.password = :password WHERE u.email = :email") - void updatePassword(@Param("email") String email, @Param("password") String password); -} diff --git a/src/main/java/project/flipnote/user/service/UserService.java b/src/main/java/project/flipnote/user/service/UserService.java index 5ffcf072..0cc8e713 100644 --- a/src/main/java/project/flipnote/user/service/UserService.java +++ b/src/main/java/project/flipnote/user/service/UserService.java @@ -1,79 +1,61 @@ package project.flipnote.user.service; -import java.util.List; import java.util.Objects; -import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; -import project.flipnote.auth.service.AuthService; -import project.flipnote.auth.service.EmailVerificationService; -import project.flipnote.auth.service.TokenVersionService; +import project.flipnote.common.dto.UserCreateCommand; +import project.flipnote.common.event.UserWithdrawnEvent; import project.flipnote.common.exception.BizException; -import project.flipnote.user.entity.User; -import project.flipnote.user.entity.UserOAuthLink; +import project.flipnote.user.entity.UserProfile; import project.flipnote.user.entity.UserStatus; import project.flipnote.user.exception.UserErrorCode; import project.flipnote.user.model.MyInfoResponse; -import project.flipnote.user.model.ChangePasswordRequest; -import project.flipnote.user.model.SocialLinksResponse; import project.flipnote.user.model.UserInfoResponse; -import project.flipnote.user.model.UserRegisterRequest; -import project.flipnote.user.model.UserRegisterResponse; import project.flipnote.user.model.UserUpdateRequest; import project.flipnote.user.model.UserUpdateResponse; -import project.flipnote.user.repository.UserOAuthLinkRepository; -import project.flipnote.user.repository.UserRepository; +import project.flipnote.user.repository.UserProfileRepository; @RequiredArgsConstructor @Transactional(readOnly = true) @Service public class UserService { - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - private final AuthService authService; - private final TokenVersionService tokenVersionService; - private final EmailVerificationService emailVerificationService; - private final UserOAuthLinkRepository userOAuthLinkRepository; + private final UserProfileRepository userProfileRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional - public UserRegisterResponse register(UserRegisterRequest req) { - String email = req.email(); - String phone = req.getNormalizedPhone(); - - validateEmailDuplicate(email); - validatePhoneDuplicate(phone); - emailVerificationService.validateVerified(email); - - User user = User.builder() - .email(email) - .password(passwordEncoder.encode(req.password())) - .name(req.name()) - .nickname(req.nickname()) - .smsAgree(req.smsAgree()) - .phone(phone) - .profileImageUrl(req.profileImageUrl()) + public Long createUser(UserCreateCommand command) { + validateEmailDuplicate(command.email()); + validatePhoneDuplicate(command.phone()); + + UserProfile user = UserProfile.builder() + .email(command.email()) + .name(command.name()) + .nickname(command.nickname()) + .profileImageUrl(command.profileImageUrl()) + .phone(command.phone()) + .smsAgree(command.smsAgree()) .build(); - User savedUser = userRepository.save(user); - return UserRegisterResponse.from(savedUser.getId()); + UserProfile savedUser = userProfileRepository.save(user); + return savedUser.getId(); } @Transactional - public void unregister(Long userId) { - User user = findActiveUserById(userId); - - user.unregister(); + public void withdraw(Long userId) { + UserProfile user = findActiveUserById(userId); + user.withdraw(); - tokenVersionService.incrementTokenVersion(userId); + eventPublisher.publishEvent(new UserWithdrawnEvent(userId)); } @Transactional public UserUpdateResponse update(Long userId, UserUpdateRequest req) { - User user = findActiveUserById(userId); + UserProfile user = findActiveUserById(userId); String phone = req.getNormalizedPhone(); if (!Objects.equals(user.getPhone(), phone)) { @@ -86,60 +68,34 @@ public UserUpdateResponse update(Long userId, UserUpdateRequest req) { } public MyInfoResponse getMyInfo(Long userId) { - User user = findActiveUserById(userId); + UserProfile user = findActiveUserById(userId); return MyInfoResponse.from(user); } public UserInfoResponse getUserInfo(Long userId) { - User user = findActiveUserById(userId); + UserProfile user = findActiveUserById(userId); return UserInfoResponse.from(user); } - @Transactional - public void changePassword(Long userId, ChangePasswordRequest req) { - User user = findActiveUserById(userId); - - authService.validatePasswordMatch(req.currentPassword(), user.getPassword()); - - user.changePassword(passwordEncoder.encode(req.newPassword())); - - tokenVersionService.incrementTokenVersion(userId); - } - - public SocialLinksResponse getSocialLinks(Long userId) { - List links = userOAuthLinkRepository.findByUser_Id(userId); - - return SocialLinksResponse.from(links); - } - - @Transactional - public void deleteSocialLink(Long userId, Long socialLinkId) { - if (!userOAuthLinkRepository.existsByIdAndUser_Id(socialLinkId, userId)) { - throw new BizException(UserErrorCode.SOCIAL_LINK_NOT_FOUND); - } - - userOAuthLinkRepository.deleteById(socialLinkId); - } - - private User findActiveUserById(Long userId) { - return userRepository.findByIdAndStatus(userId, UserStatus.ACTIVE) + private UserProfile findActiveUserById(Long userId) { + return userProfileRepository.findByIdAndStatus(userId, UserStatus.ACTIVE) .orElseThrow(() -> new BizException(UserErrorCode.USER_NOT_FOUND)); } private void validateEmailDuplicate(String email) { - if (userRepository.existsByEmail(email)) { + if (userProfileRepository.existsByEmail(email)) { throw new BizException(UserErrorCode.DUPLICATE_EMAIL); } } - private void validatePhoneDuplicate(String phone) { + public void validatePhoneDuplicate(String phone) { if (Objects.isNull(phone)) { return; } - if (userRepository.existsByPhone(phone)) { + if (userProfileRepository.existsByPhone(phone)) { throw new BizException(UserErrorCode.DUPLICATE_PHONE); } } diff --git a/src/test/java/project/flipnote/auth/service/AuthServiceTest.java b/src/test/java/project/flipnote/auth/service/AuthServiceTest.java deleted file mode 100644 index d3ba70c2..00000000 --- a/src/test/java/project/flipnote/auth/service/AuthServiceTest.java +++ /dev/null @@ -1,410 +0,0 @@ -package project.flipnote.auth.service; - -import static org.assertj.core.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.BDDMockito.*; - -import java.util.Optional; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.security.crypto.password.PasswordEncoder; - -import project.flipnote.auth.event.EmailVerificationSendEvent; -import project.flipnote.auth.event.PasswordResetCreateEvent; -import project.flipnote.auth.exception.AuthErrorCode; -import project.flipnote.auth.model.EmailVerificationConfirmRequest; -import project.flipnote.auth.model.EmailVerificationRequest; -import project.flipnote.auth.model.PasswordResetCreateRequest; -import project.flipnote.auth.model.PasswordResetRequest; -import project.flipnote.auth.model.TokenPair; -import project.flipnote.auth.model.UserLoginRequest; -import project.flipnote.auth.repository.EmailVerificationRedisRepository; -import project.flipnote.auth.repository.PasswordResetRedisRepository; -import project.flipnote.auth.repository.TokenBlacklistRedisRepository; -import project.flipnote.auth.util.PasswordResetTokenGenerator; -import project.flipnote.auth.util.VerificationCodeGenerator; -import project.flipnote.common.config.ClientProperties; -import project.flipnote.common.exception.BizException; -import project.flipnote.common.security.dto.UserAuth; -import project.flipnote.common.security.exception.CustomSecurityException; -import project.flipnote.common.security.exception.SecurityErrorCode; -import project.flipnote.common.security.jwt.JwtComponent; -import project.flipnote.fixture.UserFixture; -import project.flipnote.user.entity.User; -import project.flipnote.user.entity.UserStatus; -import project.flipnote.user.repository.UserRepository; - -@DisplayName("인증 서비스 단위 테스트") -@ExtendWith(MockitoExtension.class) -class AuthServiceTest { - - @InjectMocks - AuthService authService; - - @Mock - EmailVerificationRedisRepository emailVerificationRedisRepository; - - @Mock - UserRepository userRepository; - - @Mock - ApplicationEventPublisher eventPublisher; - - @Mock - PasswordEncoder passwordEncoder; - - @Mock - JwtComponent jwtComponent; - - @Mock - TokenBlacklistRedisRepository tokenBlacklistRedisRepository; - - @Mock - VerificationCodeGenerator verificationCodeGenerator; - - @Mock - PasswordResetRedisRepository passwordResetRedisRepository; - - @Mock - PasswordResetTokenGenerator passwordResetTokenGenerator; - - @Mock - ClientProperties clientProperties; - - @DisplayName("이메일 인증번호 전송 테스트") - @Nested - class SendEmailVerificationCode { - - @DisplayName("성공") - @Test - void success() { - String email = "test@test.com"; - String code = "123456"; - EmailVerificationRequest req = new EmailVerificationRequest(email); - - given(userRepository.existsByEmail(anyString())).willReturn(false); - given(emailVerificationRedisRepository.existCode(anyString())).willReturn(false); - given(verificationCodeGenerator.generateVerificationCode(anyInt())).willReturn(code); - - authService.sendEmailVerificationCode(req); - - verify(emailVerificationRedisRepository).saveCode(eq("test@test.com"), eq(code)); - verify(eventPublisher, times(1)).publishEvent(any(EmailVerificationSendEvent.class)); - } - - @DisplayName("가입된 이메일인 경우 예외 발생") - @Test - void fail_existingEmail() { - EmailVerificationRequest req = new EmailVerificationRequest("test@test.com"); - - given(userRepository.existsByEmail(any(String.class))).willReturn(true); - - BizException exception = assertThrows(BizException.class, () -> authService.sendEmailVerificationCode(req)); - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.EXISTING_EMAIL); - - verify(emailVerificationRedisRepository, never()).saveCode(any(String.class), any(String.class)); - verify(eventPublisher, never()).publishEvent(any(EmailVerificationSendEvent.class)); - } - - @DisplayName("이미 발급된 인증번호가 존재할 경우 예외 발생") - @Test - void fail_alreadyIssuedVerificationCode() { - EmailVerificationRequest req = new EmailVerificationRequest("test@test.com"); - - given(userRepository.existsByEmail(any(String.class))).willReturn(false); - given(emailVerificationRedisRepository.existCode(any(String.class))).willReturn(true); - - BizException exception = assertThrows(BizException.class, () -> authService.sendEmailVerificationCode(req)); - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.ALREADY_ISSUED_VERIFICATION_CODE); - - verify(emailVerificationRedisRepository, never()).saveCode(any(String.class), any(String.class)); - verify(eventPublisher, never()).publishEvent(any(EmailVerificationSendEvent.class)); - } - } - - @DisplayName("이메일 인증번호 확인 테스트") - @Nested - class ConfirmEmailVerificationCode { - - @DisplayName("성공") - @Test - void success() { - EmailVerificationConfirmRequest req = new EmailVerificationConfirmRequest("test@test.com", "123456"); - - given(emailVerificationRedisRepository.findCode("test@test.com")) - .willReturn(Optional.of("123456")); - - authService.confirmEmailVerificationCode(req); - - verify(emailVerificationRedisRepository, times(1)).deleteCode(any(String.class)); - verify(emailVerificationRedisRepository, times(1)).markAsVerified(any(String.class)); - } - - @DisplayName("발급된 인증번호가 없는 경우 예외 발생") - @Test - void fail_notIssuedVerificationCode() { - EmailVerificationConfirmRequest req = new EmailVerificationConfirmRequest("test@test.com", "123456"); - - given(emailVerificationRedisRepository.findCode("test@test.com")).willReturn(Optional.empty()); - - BizException exception = assertThrows( - BizException.class, - () -> authService.confirmEmailVerificationCode(req) - ); - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.NOT_ISSUED_VERIFICATION_CODE); - - verify(emailVerificationRedisRepository, never()).deleteCode(any(String.class)); - verify(emailVerificationRedisRepository, never()).markAsVerified(any(String.class)); - } - - @DisplayName("잘못된 인증번호인 경우 예외 발생") - @Test - void fail_invalidVerificationCode() { - EmailVerificationConfirmRequest req = new EmailVerificationConfirmRequest("test@test.com", "123456"); - - given(emailVerificationRedisRepository.findCode("test@test.com")) - .willReturn(Optional.of("654321")); - - BizException exception = assertThrows( - BizException.class, - () -> authService.confirmEmailVerificationCode(req) - ); - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_VERIFICATION_CODE); - - verify(emailVerificationRedisRepository, never()).deleteCode(any(String.class)); - verify(emailVerificationRedisRepository, never()).markAsVerified(any(String.class)); - } - } - - @DisplayName("로그인 테스트") - @Nested - class Login { - - @DisplayName("성공") - @Test - void success() { - UserLoginRequest req = new UserLoginRequest("test@example.com", "testPass"); - - User foundUser = UserFixture.createActiveUser(); - - TokenPair expectedTokenPair = new TokenPair("access-token", "refresh-token"); - - given(userRepository.findByEmailAndStatus(req.email(), UserStatus.ACTIVE)) - .willReturn(Optional.of(foundUser)); - given(passwordEncoder.matches(req.password(), foundUser.getPassword())) - .willReturn(true); - given(jwtComponent.generateTokenPair(foundUser)).willReturn(expectedTokenPair); - - TokenPair resultTokenPair = authService.login(req); - - assertThat(resultTokenPair).isNotNull(); - assertThat(resultTokenPair.accessToken()).isEqualTo(expectedTokenPair.accessToken()); - assertThat(resultTokenPair.refreshToken()).isEqualTo(expectedTokenPair.refreshToken()); - - verify(userRepository).findByEmailAndStatus(anyString(), any(UserStatus.class)); - verify(passwordEncoder).matches(anyString(), anyString()); - verify(jwtComponent).generateTokenPair(any(User.class)); - } - - @Test - @DisplayName("이메일이 존재하지 않는 경우 예외 발생") - void fail_invalidCredentials_wrongEmail() { - UserLoginRequest req = new UserLoginRequest("wrong@test.com", "testPass"); - - when(userRepository.findByEmailAndStatus(req.email(), UserStatus.ACTIVE)) - .thenReturn(Optional.empty()); - - BizException exception = assertThrows( - BizException.class, - () -> authService.login(req) - ); - - assertThat(exception).isNotNull(); - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_CREDENTIALS); - - verify(passwordEncoder, never()).matches(anyString(), anyString()); - verify(jwtComponent, never()).generateTokenPair(any(User.class)); - } - - @Test - @DisplayName("비밀번호가 일치하지 않는 경우 예외 발생") - void fail_invalidCredentials_wrongPassword() { - UserLoginRequest req = new UserLoginRequest("wrong@test.com", "wrongPass"); - - User foundUser = UserFixture.createActiveUser(); - - given(userRepository.findByEmailAndStatus(req.email(), UserStatus.ACTIVE)) - .willReturn(Optional.of(foundUser)); - given(passwordEncoder.matches(req.password(), foundUser.getPassword())) - .willReturn(false); - - BizException exception = assertThrows( - BizException.class, - () -> authService.login(req) - ); - - assertThat(exception).isNotNull(); - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_CREDENTIALS); - - verify(jwtComponent, never()).generateTokenPair(any(User.class)); - } - } - - @DisplayName("토큰 갱신 테스트") - @Nested - class RefreshToken { - - @DisplayName("성공") - @Test - void success() { - String refreshToken = "valid-refresh-token"; - long expirationMillis = System.currentTimeMillis() + 100000; - UserAuth userAuth = UserAuth.from(UserFixture.createActiveUser()); - TokenPair expectedTokenPair = new TokenPair("new-access-token", "new-refresh-token"); - - given(tokenBlacklistRedisRepository.exist(refreshToken)).willReturn(false); - given(jwtComponent.getExpirationMillis(refreshToken)).willReturn(expirationMillis); - given(jwtComponent.extractUserAuthFromToken(refreshToken)).willReturn(userAuth); - given(jwtComponent.generateTokenPair(userAuth)).willReturn(expectedTokenPair); - - TokenPair resultTokenPair = authService.refreshToken(refreshToken); - - assertThat(resultTokenPair).isNotNull(); - assertThat(resultTokenPair.accessToken()).isEqualTo(expectedTokenPair.accessToken()); - assertThat(resultTokenPair.refreshToken()).isEqualTo(expectedTokenPair.refreshToken()); - - verify(tokenBlacklistRedisRepository, times(1)).exist(refreshToken); - verify(jwtComponent, times(1)).getExpirationMillis(refreshToken); - verify(tokenBlacklistRedisRepository, times(1)).save(refreshToken, expirationMillis); - verify(jwtComponent, times(1)).extractUserAuthFromToken(refreshToken); - verify(jwtComponent, times(1)).generateTokenPair(userAuth); - } - - @DisplayName("이미 사용된 토큰(블랙리스트)인 경우 예외 발생") - @Test - void fail_whenTokenIsBlacklisted() { - String refreshToken = "blacklisted-refresh-token"; - given(tokenBlacklistRedisRepository.exist(refreshToken)).willReturn(true); - - BizException exception = assertThrows(BizException.class, () -> authService.refreshToken(refreshToken)); - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_REFRESH_TOKEN); - - verify(jwtComponent, never()).getExpirationMillis(anyString()); - verify(tokenBlacklistRedisRepository, never()).save(anyString(), anyLong()); - verify(jwtComponent, never()).extractUserAuthFromToken(anyString()); - } - - @DisplayName("유효하지 않은 토큰으로 갱신 시도 시 예외 발생") - @Test - void fail_whenTokenIsInvalid() { - String invalidToken = "invalid-refresh-token"; - long expirationMillis = 1000L; - given(tokenBlacklistRedisRepository.exist(invalidToken)).willReturn(false); - given(jwtComponent.getExpirationMillis(invalidToken)).willReturn(expirationMillis); - given(jwtComponent.extractUserAuthFromToken(invalidToken)) - .willThrow(new CustomSecurityException(SecurityErrorCode.NOT_VALID_JWT_TOKEN)); - - CustomSecurityException exception = assertThrows( - CustomSecurityException.class, - () -> authService.refreshToken(invalidToken) - ); - assertThat(exception.getErrorCode()).isEqualTo(SecurityErrorCode.NOT_VALID_JWT_TOKEN); - - verify(tokenBlacklistRedisRepository, times(1)).save(invalidToken, expirationMillis); - verify(jwtComponent, never()).generateTokenPair(any(UserAuth.class)); - } - } - - @DisplayName("비밀번호 재설정 링크 전송 테스트") - @Nested - class RequestPasswordReset { - - @DisplayName("성공") - @Test - void success() { - String email = "test@test.com"; - String token = "test-token"; - PasswordResetCreateRequest req = new PasswordResetCreateRequest(email); - - given(passwordResetRedisRepository.hasActiveToken(anyString())).willReturn(false); - given(userRepository.existsByEmailAndStatus(anyString(), any())).willReturn(true); - given(passwordResetTokenGenerator.generateToken()).willReturn(token); - - authService.requestPasswordReset(req); - - verify(passwordResetRedisRepository, times(1)).saveToken(eq(email), eq(token)); - verify(eventPublisher, times(1)).publishEvent(any(PasswordResetCreateEvent.class)); - } - - @DisplayName("비밀번호 재설정 링크가 존재하는 경우 예외 발생") - @Test - void fail_alreadySentPasswordResetLink() { - PasswordResetCreateRequest req = new PasswordResetCreateRequest("test@test.com"); - - given(passwordResetRedisRepository.hasActiveToken(anyString())).willReturn(true); - - BizException exception = assertThrows(BizException.class, () -> authService.requestPasswordReset(req)); - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK); - - verify(passwordResetRedisRepository, never()).saveToken(anyString(), anyString()); - verify(eventPublisher, never()).publishEvent(any(PasswordResetCreateEvent.class)); - } - - @DisplayName("존재하지 않는 이메일의 경우 동작 안함 (예외는 발생 안함)") - @Test - void fail_notExistEmail() { - PasswordResetCreateRequest req = new PasswordResetCreateRequest("test@test.com"); - - given(passwordResetRedisRepository.hasActiveToken(anyString())).willReturn(false); - given(userRepository.existsByEmailAndStatus(anyString(), any())).willReturn(false); - - authService.requestPasswordReset(req); - - verify(passwordResetRedisRepository, never()).saveToken(anyString(), anyString()); - verify(eventPublisher, never()).publishEvent(any(PasswordResetCreateEvent.class)); - } - } - - @DisplayName("비밀번호 재설정 테스트") - @Nested - class ResetPassword { - - @DisplayName("성공") - @Test - void success() { - String email = "test@test.com"; - String encodedPass = "encodedPass"; - String token = "token"; - PasswordResetRequest req = new PasswordResetRequest(token, "testPass"); - - given(passwordResetRedisRepository.findEmailByToken(anyString())).willReturn(Optional.of(email)); - given(passwordEncoder.encode(anyString())).willReturn(encodedPass); - - authService.resetPassword(req); - - verify(userRepository, times(1)).updatePassword(eq(email), eq(encodedPass)); - verify(passwordResetRedisRepository, times(1)).deleteToken(eq(email), eq(token)); - } - - @DisplayName("토큰이 존재하지 않거나 만료되었을 때 예외 발생") - @Test - void fail_invalidPasswordResetToken() { - PasswordResetRequest req = new PasswordResetRequest("token", "testPass"); - - given(passwordResetRedisRepository.findEmailByToken(anyString())).willReturn(Optional.empty()); - - BizException exception = assertThrows(BizException.class, () -> authService.resetPassword(req)); - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_PASSWORD_RESET_TOKEN); - - verify(userRepository, never()).updatePassword(anyString(), anyString()); - verify(passwordResetRedisRepository, never()).deleteToken(anyString(), anyString()); - } - } -} diff --git a/src/test/java/project/flipnote/fixture/UserFixture.java b/src/test/java/project/flipnote/fixture/UserFixture.java deleted file mode 100644 index f457fabf..00000000 --- a/src/test/java/project/flipnote/fixture/UserFixture.java +++ /dev/null @@ -1,27 +0,0 @@ -package project.flipnote.fixture; - -import org.springframework.test.util.ReflectionTestUtils; - -import project.flipnote.user.entity.User; - -public class UserFixture { - - public static final String ENCODED_PASSWORD = "encodedPass"; - public static final String USER_EMAIL = "test@test.com"; - - public static User createActiveUser() { - User user = User.builder() - .email(USER_EMAIL) - .password(ENCODED_PASSWORD) - .nickname("테스트닉네임") - .name("테스트이름") - .phone("+821012345678") - .smsAgree(true) - .profileImageUrl("test_image_url") - .build(); - - ReflectionTestUtils.setField(user, "id", 1L); - - return user; - } -} diff --git a/src/test/java/project/flipnote/group/service/GroupServiceTest.java b/src/test/java/project/flipnote/group/service/GroupServiceTest.java deleted file mode 100644 index dd4f1acc..00000000 --- a/src/test/java/project/flipnote/group/service/GroupServiceTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package project.flipnote.group.service; - -import static org.assertj.core.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.BDDMockito.*; - -import java.util.List; -import java.util.Optional; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.test.util.ReflectionTestUtils; -import project.flipnote.auth.repository.EmailVerificationRedisRepository; -import project.flipnote.common.exception.BizException; -import project.flipnote.common.security.dto.UserAuth; -import project.flipnote.fixture.UserFixture; -import project.flipnote.group.entity.Category; -import project.flipnote.group.entity.Group; -import project.flipnote.group.entity.GroupPermission; -import project.flipnote.group.model.GroupCreateRequest; -import project.flipnote.group.model.GroupCreateResponse; -import project.flipnote.group.repository.GroupMemberRepository; -import project.flipnote.group.repository.GroupPermissionRepository; -import project.flipnote.group.repository.GroupRepository; -import project.flipnote.group.repository.GroupRolePermissionRepository; -import project.flipnote.user.entity.User; -import project.flipnote.user.entity.UserStatus; -import project.flipnote.user.repository.UserRepository; - -@ExtendWith(MockitoExtension.class) -class GroupServiceTest { - - private static final Logger log = LoggerFactory.getLogger(GroupServiceTest.class); - @InjectMocks - GroupService groupService; - - @Mock - GroupRepository groupRepository; - - @Mock - GroupPermissionRepository groupPermissionRepository; - - @Mock - GroupRolePermissionRepository groupRolePermissionRepository; - - @Mock - UserRepository userRepository; - - @Mock - EmailVerificationRedisRepository emailVerificationRedisRepository; - - @Mock - GroupMemberRepository groupMemberRepository; - - User user; - UserAuth userAuth; - - @BeforeEach - void before() { - user = UserFixture.createActiveUser(); - userAuth = new UserAuth(user.getId(), user.getEmail(), user.getRole(), user.getTokenVersion()); - - // 사용자 검증 로직 - given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); - } - - @Test - void 그룹_생성_성공() { - // given - GroupCreateRequest req = new GroupCreateRequest("그룹1", Category.ENGLISH, "설명1", true, true, 100, "www.~~~"); - Group group = Group.builder().name(req.name()).build(); - ReflectionTestUtils.setField(group, "id", 1L); - - given(groupRepository.save(any(Group.class))).willReturn(group); - - // 그룹 퍼미션 미리 세팅 - List permissions = List.of( - GroupPermission.builder().name("INVITE").build(), - GroupPermission.builder().name("KICK").build(), - GroupPermission.builder().name("JOIN_REQUEST_MANAGE").build() - ); - given(groupPermissionRepository.findAll()).willReturn(permissions); - - // when - GroupCreateResponse response = groupService.create(userAuth, req); - - // then - assertThat(response.groupId()).isEqualTo(1L); - } - - @Test - void 그룹_생성_실패_음수() { - // given - GroupCreateRequest req = new GroupCreateRequest("그룹1", Category.ENGLISH, "설명1", true, true, -100, "www.~~~"); - Group group = Group.builder().name(req.name()).build(); - ReflectionTestUtils.setField(group, "id", 1L); - - - // when & then - assertThrows(BizException.class, () -> groupService.create(userAuth, req)); - } - - @Test - void 그룹_생성_실패_초과() { - // given - GroupCreateRequest req = new GroupCreateRequest("그룹1", Category.ENGLISH, "설명1", true, true, 200, "www.~~~"); - Group group = Group.builder().name(req.name()).build(); - ReflectionTestUtils.setField(group, "id", 1L); - - // when & then - assertThrows(BizException.class, () -> groupService.create(userAuth, req)); - } -} diff --git a/src/test/java/project/flipnote/user/service/UserServiceTest.java b/src/test/java/project/flipnote/user/service/UserServiceTest.java deleted file mode 100644 index dfccb252..00000000 --- a/src/test/java/project/flipnote/user/service/UserServiceTest.java +++ /dev/null @@ -1,448 +0,0 @@ -package project.flipnote.user.service; - -import static org.assertj.core.api.AssertionsForClassTypes.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.BDDMockito.*; - -import java.util.List; -import java.util.Optional; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; - -import project.flipnote.auth.exception.AuthErrorCode; -import project.flipnote.auth.repository.TokenVersionRedisRepository; -import project.flipnote.auth.service.AuthService; -import project.flipnote.auth.service.EmailVerificationService; -import project.flipnote.auth.service.TokenVersionService; -import project.flipnote.common.exception.BizException; -import project.flipnote.fixture.UserFixture; -import project.flipnote.user.entity.User; -import project.flipnote.user.entity.UserOAuthLink; -import project.flipnote.user.entity.UserStatus; -import project.flipnote.user.exception.UserErrorCode; -import project.flipnote.user.model.ChangePasswordRequest; -import project.flipnote.user.model.MyInfoResponse; -import project.flipnote.user.model.SocialLinksResponse; -import project.flipnote.user.model.UserInfoResponse; -import project.flipnote.user.model.UserRegisterRequest; -import project.flipnote.user.model.UserRegisterResponse; -import project.flipnote.user.model.UserUpdateRequest; -import project.flipnote.user.model.UserUpdateResponse; -import project.flipnote.user.repository.UserOAuthLinkRepository; -import project.flipnote.user.repository.UserRepository; - -@DisplayName("회원 서비스 단위 테스트") -@ExtendWith(MockitoExtension.class) -class UserServiceTest { - - @InjectMocks - UserService userService; - - @Mock - UserRepository userRepository; - - @Mock - PasswordEncoder passwordEncoder; - - @Mock - TokenVersionRedisRepository tokenVersionRedisRepository; - - @Mock - AuthService authService; - - @Mock - TokenVersionService tokenVersionService; - - @Mock - EmailVerificationService emailVerificationService; - - @Mock - UserOAuthLinkRepository userOAuthLinkRepository; - - @DisplayName("회원가입 테스트") - @Nested - class Register { - - @DisplayName("성공") - @Test - void success() { - User user = UserFixture.createActiveUser(); - UserRegisterRequest req = new UserRegisterRequest( - "test@test.com", "testPass", "테스트", "테스트", false, "010-1234-5678", "" - ); - - given(userRepository.existsByEmail(any(String.class))).willReturn(false); - given(userRepository.existsByPhone(any(String.class))).willReturn(false); - given(passwordEncoder.encode(any(String.class))).willReturn("encodedPass"); - given(userRepository.save(any(User.class))).willReturn(user); - - UserRegisterResponse res = userService.register(req); - - assertThat(res.userId()).isEqualTo(user.getId()); - } - - @DisplayName("휴대전화 번호가 null일 때 성공") - @Test - void success_ifPhoneIsNull() { - User user = UserFixture.createActiveUser(); - UserRegisterRequest req = new UserRegisterRequest( - "test@test.com", "testPass", "테스트", "테스트", false, null, null - ); - - given(userRepository.existsByEmail(any(String.class))).willReturn(false); - given(passwordEncoder.encode(any(String.class))).willReturn("encodedPass"); - given(userRepository.save(any(User.class))).willReturn(user); - - UserRegisterResponse res = userService.register(req); - - assertThat(res.userId()).isEqualTo(user.getId()); - } - - @DisplayName("이메일 중복 시 예외 발생") - @Test - void fail_duplicateEmail() { - UserRegisterRequest req = new UserRegisterRequest( - "test@test.com", "testPass", "테스트", "테스트", false, "010-1234-5678", "" - ); - - given(userRepository.existsByEmail(any(String.class))).willReturn(true); - - BizException exception = assertThrows(BizException.class, () -> userService.register(req)); - assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.DUPLICATE_EMAIL); - - verify(userRepository, never()).existsByPhone(any(String.class)); - verify(userRepository, never()).save(any(User.class)); - } - - @DisplayName("전화번호 중복 시 예외 발생") - @Test - void fail_duplicatePhone() { - UserRegisterRequest req = new UserRegisterRequest( - "test@test.com", "testPass", "테스트", "테스트", false, "010-1234-5678", "" - ); - - given(userRepository.existsByEmail(any(String.class))).willReturn(false); - given(userRepository.existsByPhone(any(String.class))).willReturn(true); - - BizException exception = assertThrows(BizException.class, () -> userService.register(req)); - assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.DUPLICATE_PHONE); - - verify(userRepository, never()).save(any()); - } - - @DisplayName("이메일 인증이 안 된 경우 예외 발생") - @Test - void fail_unverifiedEmail() { - UserRegisterRequest req = new UserRegisterRequest( - "test@test.com", "testPass", "테스트", "테스트", false, "010-1234-5678", "" - ); - - given(userRepository.existsByEmail(anyString())).willReturn(false); - given(userRepository.existsByPhone(anyString())).willReturn(false); - doThrow(new BizException(AuthErrorCode.UNVERIFIED_EMAIL)) - .when(emailVerificationService) - .validateVerified(anyString()); - - BizException exception = assertThrows(BizException.class, () -> userService.register(req)); - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.UNVERIFIED_EMAIL); - - verify(userRepository, never()).save(any(User.class)); - } - } - - @DisplayName("회원 탈퇴 테스트") - @Nested - class Unregister { - - @DisplayName("성공") - @Test - void success() { - User user = spy(UserFixture.createActiveUser()); - - given(userRepository.findByIdAndStatus(anyLong(), any(UserStatus.class))).willReturn(Optional.of(user)); - - userService.unregister(user.getId()); - - verify(user, times(1)).unregister(); - verify(tokenVersionService, times(1)).incrementTokenVersion(user.getId()); - } - - @DisplayName("회원 id가 존재하지 않는 경우 예외 발생") - @Test - void fail_userNotFound() { - given(userRepository.findByIdAndStatus(anyLong(), any(UserStatus.class))).willReturn(Optional.empty()); - - BizException exception = assertThrows(BizException.class, () -> userService.unregister(1L)); - assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.USER_NOT_FOUND); - } - } - - @DisplayName("회원 정보 수정 테스트") - @Nested - class Update { - - @DisplayName("성공") - @Test - void success() { - User user = UserFixture.createActiveUser(); - UserUpdateRequest req = new UserUpdateRequest( - "새로운닉네임", "010-9876-5432", true, "new/image.jpg" - ); - String normalizedPhone = req.getNormalizedPhone(); - - given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); - given(userRepository.existsByPhone(normalizedPhone)).willReturn(false); - - UserUpdateResponse res = userService.update(user.getId(), req); - - assertThat(res.userId()).isEqualTo(user.getId()); - assertThat(res.nickname()).isEqualTo(req.nickname()); - assertThat(res.phone()).isEqualTo(normalizedPhone); - assertThat(res.smsAgree()).isEqualTo(req.smsAgree()); - assertThat(res.profileImageUrl()).isEqualTo(req.profileImageUrl()); - - verify(userRepository, times(1)).findByIdAndStatus(anyLong(), any(UserStatus.class)); - verify(userRepository, times(1)).existsByPhone(anyString()); - } - - @DisplayName("동일한 전화번호로 수정 시 성공") - @Test - void success_withSamePhone() { - User user = UserFixture.createActiveUser(); - UserUpdateRequest req = new UserUpdateRequest( - "새로운닉네임", user.getPhone(), true, "new/image.jpg" - ); - String normalizedPhone = req.getNormalizedPhone(); - - given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); - - UserUpdateResponse res = userService.update(user.getId(), req); - - assertThat(res.userId()).isEqualTo(user.getId()); - assertThat(res.nickname()).isEqualTo(req.nickname()); - assertThat(res.phone()).isEqualTo(normalizedPhone); - assertThat(res.smsAgree()).isEqualTo(req.smsAgree()); - assertThat(res.profileImageUrl()).isEqualTo(req.profileImageUrl()); - - verify(userRepository, never()).existsByPhone(anyString()); - } - - @DisplayName("존재하지 않는 회원 수정 시 예외 발생") - @Test - void fail_userNotFound() { - UserUpdateRequest req = new UserUpdateRequest( - "새로운닉네임", "010-9876-5432", true, "new/image.jpg" - ); - - given(userRepository.findByIdAndStatus(anyLong(), any(UserStatus.class))).willReturn(Optional.empty()); - - BizException exception = assertThrows(BizException.class, () -> userService.update(99L, req)); - - assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.USER_NOT_FOUND); - } - - @DisplayName("중복된 전화번호로 수정 시 예외 발생") - @Test - void fail_duplicatePhone() { - User user = UserFixture.createActiveUser(); - UserUpdateRequest req = new UserUpdateRequest( - "새로운닉네임", "010-9999-9999", true, "new/image.jpg" - ); - String duplicatePhone = req.getNormalizedPhone(); - - given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); - given(userRepository.existsByPhone(duplicatePhone)).willReturn(true); - - BizException exception = assertThrows(BizException.class, () -> userService.update(user.getId(), req)); - - assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.DUPLICATE_PHONE); - } - } - - @DisplayName("내 정보 조회 테스트") - @Nested - class GetMyInfo { - - @DisplayName("성공") - @Test - void success() { - User user = UserFixture.createActiveUser(); - - given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); - - MyInfoResponse res = userService.getMyInfo(user.getId()); - - assertThat(res.userId()).isEqualTo(user.getId()); - assertThat(res.email()).isEqualTo(user.getEmail()); - assertThat(res.name()).isEqualTo(user.getName()); - assertThat(res.nickname()).isEqualTo(user.getNickname()); - assertThat(res.phone()).isEqualTo(user.getPhone()); - assertThat(res.profileImageUrl()).isEqualTo(user.getProfileImageUrl()); - assertThat(res.smsAgree()).isEqualTo(user.isSmsAgree()); - - verify(userRepository, times(1)).findByIdAndStatus(user.getId(), UserStatus.ACTIVE); - } - - @DisplayName("존재하지 않는 회원 조회 시 예외 발생") - @Test - void fail_userNotFound() { - given(userRepository.findByIdAndStatus(anyLong(), any(UserStatus.class))).willReturn(Optional.empty()); - - BizException exception = assertThrows(BizException.class, () -> userService.getMyInfo(99L)); - - assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.USER_NOT_FOUND); - } - } - - @DisplayName("다른 회원 정보 조회 테스트") - @Nested - class GetUserInfo { - - @DisplayName("성공") - @Test - void success() { - User user = UserFixture.createActiveUser(); - given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); - - UserInfoResponse res = userService.getUserInfo(user.getId()); - - assertThat(res.userId()).isEqualTo(user.getId()); - assertThat(res.nickname()).isEqualTo(user.getNickname()); - assertThat(res.profileImageUrl()).isEqualTo(user.getProfileImageUrl()); - - verify(userRepository, times(1)).findByIdAndStatus(user.getId(), UserStatus.ACTIVE); - } - - @DisplayName("존재하지 않는 회원 조회 시 예외 발생") - @Test - void fail_userNotFound() { - given(userRepository.findByIdAndStatus(anyLong(), any(UserStatus.class))).willReturn(Optional.empty()); - - BizException exception = assertThrows(BizException.class, () -> userService.getUserInfo(99L)); - - assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.USER_NOT_FOUND); - } - } - - @DisplayName("비밀번호 변경 테스트") - @Nested - class ChangePassword { - - @DisplayName("성공") - @Test - void success() { - User user = spy(UserFixture.createActiveUser()); - ChangePasswordRequest req = new ChangePasswordRequest("currentPassword123!", "newPassword123!"); - String encodedNewPassword = "encodedNewPassword"; - - given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); - given(passwordEncoder.encode(req.newPassword())).willReturn(encodedNewPassword); - - userService.changePassword(user.getId(), req); - - verify(user, times(1)).changePassword(encodedNewPassword); - verify(tokenVersionService, times(1)).incrementTokenVersion(user.getId()); - } - - @DisplayName("존재하지 않는 회원의 비밀번호 변경 시 예외 발생") - @Test - void fail_userNotFound() { - ChangePasswordRequest req = new ChangePasswordRequest("currentPassword123!", "newPassword123!"); - Long nonExistentUserId = 99L; - - given(userRepository.findByIdAndStatus(nonExistentUserId, UserStatus.ACTIVE)).willReturn(Optional.empty()); - - BizException exception = assertThrows(BizException.class, - () -> userService.changePassword(nonExistentUserId, req)); - - assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.USER_NOT_FOUND); - - verify(passwordEncoder, never()).matches(anyString(), anyString()); - verify(passwordEncoder, never()).encode(anyString()); - verify(tokenVersionRedisRepository, never()).deleteTokenVersion(anyLong()); - } - - @DisplayName("현재 비밀번호가 일치하지 않을 경우 예외 발생") - @Test - void fail_incorrectCurrentPassword() { - User user = UserFixture.createActiveUser(); - ChangePasswordRequest req = new ChangePasswordRequest("wrongPassword", "newPassword123!"); - - given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); - doThrow(new BizException(AuthErrorCode.INVALID_CREDENTIALS)) - .when(authService) - .validatePasswordMatch(req.currentPassword(), user.getPassword()); - - BizException exception = assertThrows(BizException.class, - () -> userService.changePassword(user.getId(), req)); - - assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_CREDENTIALS); - verify(authService, times(1)).validatePasswordMatch(req.currentPassword(), user.getPassword()); - verify(passwordEncoder, never()).encode(anyString()); - verify(tokenVersionRedisRepository, never()).deleteTokenVersion(anyLong()); - } - } - - @DisplayName("내 소셜 계정 목록 조회 테스트") - @Nested - class GetSocialLinks { - - @DisplayName("성공") - @Test - void success() { - User user = UserFixture.createActiveUser(); - - List links = List.of(new UserOAuthLink("google", "providerId1", user)); - - given(userOAuthLinkRepository.findByUser_Id(user.getId())).willReturn(links); - - SocialLinksResponse res = userService.getSocialLinks(user.getId()); - - assertThat(res.socialLinks()).isNotNull(); - assertThat(res.socialLinks().size()).isEqualTo(1); - assertThat(res.socialLinks().get(0).provider()).isEqualTo("google"); - } - } - - @DisplayName("소셜 연동 해제 테스트") - @Nested - class DeleteSocialLink { - - @DisplayName("성공") - @Test - void success() { - User user = UserFixture.createActiveUser(); - UserOAuthLink userOAuthLink = new UserOAuthLink("google", "providerId", user); - ReflectionTestUtils.setField(userOAuthLink, "id", 1L); - - given(userOAuthLinkRepository.existsByIdAndUser_Id(userOAuthLink.getId(), user.getId())).willReturn(true); - - userService.deleteSocialLink(user.getId(), userOAuthLink.getId()); - - verify(userOAuthLinkRepository, times(1)).deleteById(userOAuthLink.getId()); - } - - @DisplayName("회원이 연동한 소셜 계정이 아닌 경우 예외 발생") - @Test - void fail_socialLinkNotFound() { - Long userId = 1L; - Long socialLinkId = 1L; - - given(userOAuthLinkRepository.existsByIdAndUser_Id(socialLinkId, userId)).willReturn(false); - - BizException exception = assertThrows(BizException.class, - () -> userService.deleteSocialLink(userId, socialLinkId)); - assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.SOCIAL_LINK_NOT_FOUND); - - verify(userOAuthLinkRepository, never()).deleteById(anyLong()); - } - } -} \ No newline at end of file