diff --git a/backend/src/main/java/shook/shook/auth/application/AuthService.java b/backend/src/main/java/shook/shook/auth/application/AuthService.java index e86c3f421..07c6c9529 100644 --- a/backend/src/main/java/shook/shook/auth/application/AuthService.java +++ b/backend/src/main/java/shook/shook/auth/application/AuthService.java @@ -1,11 +1,8 @@ package shook.shook.auth.application; -import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import shook.shook.auth.application.dto.ReissueAccessTokenResponse; import shook.shook.auth.application.dto.TokenPair; -import shook.shook.auth.repository.InMemoryTokenPairRepository; import shook.shook.member.application.MemberService; import shook.shook.member.domain.Member; @@ -15,8 +12,7 @@ public class AuthService { private final MemberService memberService; private final OAuthProviderFinder oauthProviderFinder; - private final TokenProvider tokenProvider; - private final InMemoryTokenPairRepository inMemoryTokenPairRepository; + private final TokenService tokenService; public TokenPair oAuthLogin(final String oauthType, final String authorizationCode) { final OAuthInfoProvider oAuthInfoProvider = oauthProviderFinder.getOAuthInfoProvider(oauthType); @@ -26,24 +22,9 @@ public TokenPair oAuthLogin(final String oauthType, final String authorizationCo final Member member = memberService.findByEmail(memberInfo) .orElseGet(() -> memberService.register(memberInfo)); - final Long memberId = member.getId(); final String nickname = member.getNickname(); - final String accessToken = tokenProvider.createAccessToken(memberId, nickname); - final String refreshToken = tokenProvider.createRefreshToken(memberId, nickname); - inMemoryTokenPairRepository.addOrUpdateTokenPair(refreshToken, accessToken); - return new TokenPair(accessToken, refreshToken); - } - - public ReissueAccessTokenResponse reissueAccessTokenByRefreshToken(final String refreshToken, - final String accessToken) { - final Claims claims = tokenProvider.parseClaims(refreshToken); - final Long memberId = claims.get("memberId", Long.class); - final String nickname = claims.get("nickname", String.class); - inMemoryTokenPairRepository.validateTokenPair(refreshToken, accessToken); - final String reissuedAccessToken = tokenProvider.createAccessToken(memberId, nickname); - inMemoryTokenPairRepository.addOrUpdateTokenPair(refreshToken, reissuedAccessToken); - return new ReissueAccessTokenResponse(reissuedAccessToken); + return tokenService.updateWithNewTokenPair(memberId, nickname); } } diff --git a/backend/src/main/java/shook/shook/auth/application/TokenProvider.java b/backend/src/main/java/shook/shook/auth/application/TokenProvider.java index 42be5b23c..949514c80 100644 --- a/backend/src/main/java/shook/shook/auth/application/TokenProvider.java +++ b/backend/src/main/java/shook/shook/auth/application/TokenProvider.java @@ -50,6 +50,7 @@ private String createToken(final long memberId, final String nickname, final lon claims.put("memberId", memberId); claims.put("nickname", nickname); final Date now = new Date(); + return Jwts.builder() .setClaims(claims) .setIssuedAt(now) diff --git a/backend/src/main/java/shook/shook/auth/application/TokenService.java b/backend/src/main/java/shook/shook/auth/application/TokenService.java new file mode 100644 index 000000000..82e75a572 --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/application/TokenService.java @@ -0,0 +1,49 @@ +package shook.shook.auth.application; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shook.shook.auth.application.dto.ReissueAccessTokenResponse; +import shook.shook.auth.application.dto.TokenPair; +import shook.shook.auth.repository.InMemoryTokenPairRepository; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class TokenService { + + public static final String TOKEN_PREFIX = "Bearer "; + + private final TokenProvider tokenProvider; + private final InMemoryTokenPairRepository inMemoryTokenPairRepository; + + public String createAccessToken(final Long memberId, final String nickname) { + return tokenProvider.createAccessToken(memberId, nickname); + } + + public String createRefreshToken(final Long memberId, final String nickname) { + return tokenProvider.createRefreshToken(memberId, nickname); + } + + public ReissueAccessTokenResponse reissueAccessTokenByRefreshToken(final String refreshToken, + final String accessToken) { + inMemoryTokenPairRepository.validateTokenPair(refreshToken, accessToken); + final Claims claims = tokenProvider.parseClaims(refreshToken); + final Long memberId = claims.get("memberId", Long.class); + final String nickname = claims.get("nickname", String.class); + + final String reissuedAccessToken = tokenProvider.createAccessToken(memberId, nickname); + inMemoryTokenPairRepository.addOrUpdateTokenPair(refreshToken, reissuedAccessToken); + + return new ReissueAccessTokenResponse(reissuedAccessToken); + } + + public TokenPair updateWithNewTokenPair(final Long memberId, final String nickname) { + final String accessToken = createAccessToken(memberId, nickname); + final String refreshToken = createRefreshToken(memberId, nickname); + inMemoryTokenPairRepository.addOrUpdateTokenPair(refreshToken, accessToken); + + return new TokenPair(refreshToken, accessToken); + } +} diff --git a/backend/src/main/java/shook/shook/auth/config/AuthConfig.java b/backend/src/main/java/shook/shook/auth/config/AuthConfig.java index 0a8730471..8d4067846 100644 --- a/backend/src/main/java/shook/shook/auth/config/AuthConfig.java +++ b/backend/src/main/java/shook/shook/auth/config/AuthConfig.java @@ -45,7 +45,8 @@ private HandlerInterceptor tokenInterceptor() { .includePathPattern("/songs/*/parts/*/likes", PathMethod.PUT) .includePathPattern("/voting-songs/*/parts", PathMethod.POST) .includePathPattern("/songs/*/parts/*/comments", PathMethod.POST) - .includePathPattern("/members/*", PathMethod.DELETE); + .includePathPattern("/members/*", PathMethod.DELETE) + .includePathPattern("/members/*/nickname", PathMethod.PATCH); } @Override diff --git a/backend/src/main/java/shook/shook/auth/ui/AccessTokenReissueController.java b/backend/src/main/java/shook/shook/auth/ui/AccessTokenReissueController.java index 16eacf444..c67631db8 100644 --- a/backend/src/main/java/shook/shook/auth/ui/AccessTokenReissueController.java +++ b/backend/src/main/java/shook/shook/auth/ui/AccessTokenReissueController.java @@ -1,5 +1,7 @@ package shook.shook.auth.ui; +import static shook.shook.auth.application.TokenService.TOKEN_PREFIX; + import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -7,7 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; -import shook.shook.auth.application.AuthService; +import shook.shook.auth.application.TokenService; import shook.shook.auth.application.dto.ReissueAccessTokenResponse; import shook.shook.auth.exception.AuthorizationException; import shook.shook.auth.ui.openapi.AccessTokenReissueApi; @@ -18,22 +20,29 @@ public class AccessTokenReissueController implements AccessTokenReissueApi { private static final String EMPTY_REFRESH_TOKEN = "none"; private static final String REFRESH_TOKEN_KEY = "refreshToken"; - private static final String TOKEN_PREFIX = "Bearer "; - private final AuthService authService; + private final TokenService tokenService; @PostMapping("/reissue") public ResponseEntity reissueAccessToken( @CookieValue(value = REFRESH_TOKEN_KEY, defaultValue = EMPTY_REFRESH_TOKEN) final String refreshToken, @RequestHeader(HttpHeaders.AUTHORIZATION) final String authorization ) { + validateRefreshToken(refreshToken); + final String accessToken = extractAccessToken(authorization); + final ReissueAccessTokenResponse response = tokenService.reissueAccessTokenByRefreshToken(refreshToken, + accessToken); + + return ResponseEntity.ok(response); + } + + private void validateRefreshToken(final String refreshToken) { if (refreshToken.equals(EMPTY_REFRESH_TOKEN)) { throw new AuthorizationException.RefreshTokenNotFoundException(); } - final String accessToken = authorization.split(TOKEN_PREFIX)[1]; - final ReissueAccessTokenResponse response = - authService.reissueAccessTokenByRefreshToken(refreshToken, accessToken); + } - return ResponseEntity.ok(response); + private String extractAccessToken(final String authorization) { + return authorization.substring(TOKEN_PREFIX.length()); } } diff --git a/backend/src/main/java/shook/shook/auth/ui/AuthController.java b/backend/src/main/java/shook/shook/auth/ui/AuthController.java index e56119fcf..bcd3beb3f 100644 --- a/backend/src/main/java/shook/shook/auth/ui/AuthController.java +++ b/backend/src/main/java/shook/shook/auth/ui/AuthController.java @@ -30,6 +30,7 @@ public ResponseEntity googleLogin( final Cookie cookie = cookieProvider.createRefreshTokenCookie(tokenPair.getRefreshToken()); response.addCookie(cookie); final LoginResponse loginResponse = new LoginResponse(tokenPair.getAccessToken()); + return ResponseEntity.ok(loginResponse); } } diff --git a/backend/src/main/java/shook/shook/auth/ui/CookieProvider.java b/backend/src/main/java/shook/shook/auth/ui/CookieProvider.java index 77ee9a196..4e34c3644 100644 --- a/backend/src/main/java/shook/shook/auth/ui/CookieProvider.java +++ b/backend/src/main/java/shook/shook/auth/ui/CookieProvider.java @@ -19,6 +19,7 @@ public Cookie createRefreshTokenCookie(final String refreshToken) { cookie.setPath("/api/reissue"); cookie.setHttpOnly(true); cookie.setSecure(true); + return cookie; } } diff --git a/backend/src/main/java/shook/shook/globalexception/ErrorCode.java b/backend/src/main/java/shook/shook/globalexception/ErrorCode.java index 0b77761d5..147ca7760 100644 --- a/backend/src/main/java/shook/shook/globalexception/ErrorCode.java +++ b/backend/src/main/java/shook/shook/globalexception/ErrorCode.java @@ -60,22 +60,24 @@ public enum ErrorCode { VOTING_PART_END_OVER_SONG_LENGTH(4003, "파트의 끝 초는 노래 길이를 초과할 수 없습니다."), INVALID_VOTING_PART_LENGTH(4004, "파트의 길이는 5, 10, 15초 중 하나여야합니다."), VOTING_PART_DUPLICATE_START_AND_LENGTH_EXCEPTION(4005, - "한 노래에 동일한 파트를 두 개 이상 등록할 수 없습니다."), + "한 노래에 동일한 파트를 두 개 이상 등록할 수 없습니다."), VOTING_SONG_PART_NOT_EXIST(4006, "투표 대상 파트가 존재하지 않습니다."), VOTING_SONG_PART_FOR_OTHER_SONG(4007, "해당 파트는 다른 노래의 파트입니다."), VOTING_SONG_NOT_EXIST(4008, "존재하지 않는 투표 노래입니다."), VOTE_FOR_OTHER_PART(4009, "해당 투표는 다른 파트에 대한 투표입니다."), DUPLICATE_VOTE_EXIST(4010, "중복된 투표입니다."), - // 5000: 사용자 + // 5000: 사용자 EMPTY_EMAIL(5001, "이메일은 비어있을 수 없습니다."), TOO_LONG_EMAIL(5002, "이메일은 100자를 초과할 수 없습니다."), INVALID_EMAIL_FORM(5003, "이메일 형식에 맞지 않습니다."), EMPTY_NICKNAME(5004, "닉네임은 비어있을 수 없습니다."), - TOO_LONG_NICKNAME(5005, "닉네임은 100자를 초과할 수 없습니다."), + TOO_LONG_NICKNAME(5005, "닉네임은 20자를 초과할 수 없습니다."), EXIST_MEMBER(5006, "이미 회원가입 된 멤버입니다."), MEMBER_NOT_EXIST(5007, "존재하지 않는 멤버입니다."), + DUPLICATE_NICKNAME(5008, "중복되는 닉네임입니다."), + TOO_SHORT_NICKNAME(5009, "닉네임은 2자 이상이어야 합니다."), REQUEST_BODY_VALIDATION_FAIL(10001, ""), WRONG_REQUEST_URL(10002, "URL의 pathVariable 은 비어있을 수 없습니다."), diff --git a/backend/src/main/java/shook/shook/member/application/MemberService.java b/backend/src/main/java/shook/shook/member/application/MemberService.java index 1c95d7033..7875ae0b0 100644 --- a/backend/src/main/java/shook/shook/member/application/MemberService.java +++ b/backend/src/main/java/shook/shook/member/application/MemberService.java @@ -2,12 +2,15 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import shook.shook.auth.application.TokenProvider; import shook.shook.auth.exception.AuthorizationException; -import shook.shook.auth.ui.argumentresolver.MemberInfo; +import shook.shook.auth.repository.InMemoryTokenPairRepository; +import shook.shook.member.application.dto.NicknameUpdateRequest; import shook.shook.member.domain.Email; import shook.shook.member.domain.Member; import shook.shook.member.domain.Nickname; @@ -27,6 +30,8 @@ public class MemberService { private final MemberRepository memberRepository; private final KillingPartCommentRepository commentRepository; private final KillingPartLikeRepository likeRepository; + private final TokenProvider tokenProvider; + private final InMemoryTokenPairRepository inMemoryTokenPairRepository; @Transactional public Member register(final String email) { @@ -37,6 +42,7 @@ public Member register(final String email) { final Member newMember = new Member(email, BASIC_NICKNAME); final Member savedMember = memberRepository.save(newMember); savedMember.updateNickname(savedMember.getNickname() + savedMember.getId()); + return savedMember; } @@ -54,20 +60,27 @@ public Member findByIdAndNicknameThrowIfNotExist(final Long id, final Nickname n } @Transactional - public void deleteById(final Long id, final MemberInfo memberInfo) { - final long requestMemberId = memberInfo.getMemberId(); - final Member requestMember = findById(requestMemberId); - final Member targetMember = findById(id); - validateMemberAuthentication(requestMember, targetMember); - - final List membersExistLikes = likeRepository.findAllByMemberAndIsDeleted( - targetMember, - false - ); + public void deleteById(final Long id, final Long requestMemberId) { + final Member member = getMemberIfValidRequest(id, requestMemberId); + + final List membersExistLikes = likeRepository.findAllByMemberAndIsDeleted(member, false); membersExistLikes.forEach(KillingPartLike::updateDeletion); - commentRepository.deleteAllByMember(targetMember); - memberRepository.delete(targetMember); + commentRepository.deleteAllByMember(member); + memberRepository.delete(member); + } + + private Member getMemberIfValidRequest(final Long memberId, final Long requestMemberId) { + if (Objects.equals(memberId, requestMemberId)) { + return findById(memberId); + } + + throw new AuthorizationException.UnauthenticatedException( + Map.of( + "tokenMemberId", String.valueOf(requestMemberId), + "pathMemberId", String.valueOf(memberId) + ) + ); } private Member findById(final Long id) { @@ -77,14 +90,27 @@ private Member findById(final Long id) { )); } - private void validateMemberAuthentication(final Member requestMember, - final Member targetMember) { - if (!requestMember.equals(targetMember)) { - throw new AuthorizationException.UnauthenticatedException( - Map.of( - "tokenMemberId", String.valueOf(requestMember.getId()), - "pathMemberId", String.valueOf(targetMember.getId()) - ) + @Transactional + public boolean updateNickname(final Long memberId, final Long requestMemberId, + final NicknameUpdateRequest request) { + final Member member = getMemberIfValidRequest(memberId, requestMemberId); + final Nickname nickname = new Nickname(request.getNickname()); + + if (member.hasSameNickname(nickname)) { + return false; + } + + validateDuplicateNickname(nickname); + member.updateNickname(nickname.getValue()); + + return true; + } + + private void validateDuplicateNickname(final Nickname nickname) { + final boolean isDuplicated = memberRepository.existsMemberByNickname(nickname); + if (isDuplicated) { + throw new MemberException.ExistNicknameException( + Map.of("Nickname", nickname.getValue()) ); } } diff --git a/backend/src/main/java/shook/shook/member/application/dto/NicknameUpdateRequest.java b/backend/src/main/java/shook/shook/member/application/dto/NicknameUpdateRequest.java new file mode 100644 index 000000000..1a5e54aa5 --- /dev/null +++ b/backend/src/main/java/shook/shook/member/application/dto/NicknameUpdateRequest.java @@ -0,0 +1,18 @@ +package shook.shook.member.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Schema(description = "닉네임 변경 요청") +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +@AllArgsConstructor +@Getter +public class NicknameUpdateRequest { + + @Schema(description = "닉네임", example = "shookshook") + @NotBlank + private String nickname; +} diff --git a/backend/src/main/java/shook/shook/member/domain/Member.java b/backend/src/main/java/shook/shook/member/domain/Member.java index da34c7367..ca9e7384d 100644 --- a/backend/src/main/java/shook/shook/member/domain/Member.java +++ b/backend/src/main/java/shook/shook/member/domain/Member.java @@ -49,6 +49,14 @@ public void updateNickname(final String newNickName) { this.nickname = new Nickname(newNickName); } + public void updateNickname(final Nickname newNickname) { + this.nickname = newNickname; + } + + public boolean hasSameNickname(final Nickname nickname) { + return nickname.equals(this.nickname); + } + public String getEmail() { return email.getValue(); } diff --git a/backend/src/main/java/shook/shook/member/domain/Nickname.java b/backend/src/main/java/shook/shook/member/domain/Nickname.java index 2eb7de3fa..83a2365d3 100644 --- a/backend/src/main/java/shook/shook/member/domain/Nickname.java +++ b/backend/src/main/java/shook/shook/member/domain/Nickname.java @@ -16,7 +16,8 @@ @Embeddable public class Nickname { - private static final int NICKNAME_MAXIMUM_LENGTH = 100; + private static final int NICKNAME_MAXIMUM_LENGTH = 20; + private static final int NICKNAME_MINIMUM_LENGTH = 2; @Column(name = "nickname", length = NICKNAME_MAXIMUM_LENGTH, nullable = false) private String value; @@ -35,5 +36,10 @@ private void validateNickname(final String value) { Map.of("Nickname", value) ); } + if (value.length() < NICKNAME_MINIMUM_LENGTH) { + throw new MemberException.TooShortNicknameException( + Map.of("Nickname", value) + ); + } } } diff --git a/backend/src/main/java/shook/shook/member/domain/repository/MemberRepository.java b/backend/src/main/java/shook/shook/member/domain/repository/MemberRepository.java index 604093782..8a09abe5e 100644 --- a/backend/src/main/java/shook/shook/member/domain/repository/MemberRepository.java +++ b/backend/src/main/java/shook/shook/member/domain/repository/MemberRepository.java @@ -13,4 +13,6 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(final Email email); Optional findByIdAndNickname(final Long id, final Nickname nickname); + + boolean existsMemberByNickname(final Nickname nickname); } diff --git a/backend/src/main/java/shook/shook/member/exception/MemberException.java b/backend/src/main/java/shook/shook/member/exception/MemberException.java index df8f8eeeb..429de8b6e 100644 --- a/backend/src/main/java/shook/shook/member/exception/MemberException.java +++ b/backend/src/main/java/shook/shook/member/exception/MemberException.java @@ -93,4 +93,26 @@ public MemberNotExistException(final Map inputValuesByProperty) super(ErrorCode.MEMBER_NOT_EXIST, inputValuesByProperty); } } + + public static class ExistNicknameException extends MemberException { + + public ExistNicknameException() { + super(ErrorCode.DUPLICATE_NICKNAME); + } + + public ExistNicknameException(final Map inputValuesByProperty) { + super(ErrorCode.DUPLICATE_NICKNAME, inputValuesByProperty); + } + } + + public static class TooShortNicknameException extends MemberException { + + public TooShortNicknameException() { + super(ErrorCode.TOO_SHORT_NICKNAME); + } + + public TooShortNicknameException(final Map inputValuesByProperty) { + super(ErrorCode.TOO_SHORT_NICKNAME, inputValuesByProperty); + } + } } diff --git a/backend/src/main/java/shook/shook/member/ui/MemberController.java b/backend/src/main/java/shook/shook/member/ui/MemberController.java index 963f2553d..836e36353 100644 --- a/backend/src/main/java/shook/shook/member/ui/MemberController.java +++ b/backend/src/main/java/shook/shook/member/ui/MemberController.java @@ -1,15 +1,19 @@ package shook.shook.member.ui; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import shook.shook.auth.ui.argumentresolver.Authenticated; import shook.shook.auth.ui.argumentresolver.MemberInfo; import shook.shook.member.application.MemberService; +import shook.shook.member.application.dto.NicknameUpdateRequest; import shook.shook.member.ui.openapi.MemberApi; @RequiredArgsConstructor @@ -24,8 +28,22 @@ public ResponseEntity deleteMember( @PathVariable(name = "member_id") final Long memberId, @Authenticated final MemberInfo memberInfo ) { - memberService.deleteById(memberId, memberInfo); + memberService.deleteById(memberId, memberInfo.getMemberId()); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } + + @PatchMapping("/nickname") + public ResponseEntity updateNickname( + @Authenticated final MemberInfo memberInfo, + @PathVariable(name = "member_id") final Long memberId, + @Valid @RequestBody final NicknameUpdateRequest request + ) { + final boolean isUpdated = memberService.updateNickname(memberId, memberInfo.getMemberId(), request); + if (isUpdated) { + return ResponseEntity.ok().build(); + } + + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/shook/shook/member/ui/openapi/MemberApi.java b/backend/src/main/java/shook/shook/member/ui/openapi/MemberApi.java index 36c0f79ac..31ca1799b 100644 --- a/backend/src/main/java/shook/shook/member/ui/openapi/MemberApi.java +++ b/backend/src/main/java/shook/shook/member/ui/openapi/MemberApi.java @@ -2,13 +2,19 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import shook.shook.auth.ui.argumentresolver.Authenticated; import shook.shook.auth.ui.argumentresolver.MemberInfo; +import shook.shook.member.application.dto.NicknameUpdateRequest; @Tag(name = "Member", description = "회원 관리 API") public interface MemberApi { @@ -31,4 +37,49 @@ ResponseEntity deleteMember( @PathVariable(name = "member_id") final Long memberId, @Authenticated final MemberInfo memberInfo ); + + @Operation( + summary = "닉네임 변경", + description = "닉네임을 변경한다." + ) + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "닉네임 변경 성공" + ), + @ApiResponse( + responseCode = "204", + description = "동일한 닉네임으로 변경하여 변경된 닉네임이 없음" + ), + @ApiResponse( + responseCode = "400", + description = "중복된 닉네임, 20자가 넘는 닉네임, 공백 닉네임의 이유로 닉네임 변경 실패" + ) + } + ) + @Parameters( + value = { + @Parameter( + name = "memberInfo", + hidden = true + ), + @Parameter( + name = "member_id", + description = "닉네임을 변경할 회원 id", + required = true + ), + @Parameter( + name = "nickname", + description = "변경할 닉네임", + required = true + ) + } + ) + @PatchMapping("/nickname") + ResponseEntity updateNickname( + @Authenticated final MemberInfo memberInfo, + @PathVariable(name = "member_id") final Long memberId, + @Valid @RequestBody final NicknameUpdateRequest request + ); } diff --git a/backend/src/test/java/shook/shook/auth/application/AuthServiceTest.java b/backend/src/test/java/shook/shook/auth/application/AuthServiceTest.java index 3e2bd511d..19a33694b 100644 --- a/backend/src/test/java/shook/shook/auth/application/AuthServiceTest.java +++ b/backend/src/test/java/shook/shook/auth/application/AuthServiceTest.java @@ -1,7 +1,6 @@ package shook.shook.auth.application; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @@ -13,9 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; -import shook.shook.auth.application.dto.ReissueAccessTokenResponse; import shook.shook.auth.application.dto.TokenPair; -import shook.shook.auth.exception.TokenException; import shook.shook.auth.repository.InMemoryTokenPairRepository; import shook.shook.member.application.MemberService; import shook.shook.member.domain.Member; @@ -49,13 +46,16 @@ class AuthServiceTest { private AuthService authService; + private TokenService tokenService; + @BeforeEach void setUp() { tokenProvider = new TokenProvider( 100000L, 1000000L, "asdfsdsvsdf2esvsdvsdvs23"); - authService = new AuthService(memberService, oauthProviderFinder, tokenProvider, inMemoryTokenPairRepository); + tokenService = new TokenService(tokenProvider, inMemoryTokenPairRepository); + authService = new AuthService(memberService, oauthProviderFinder, tokenService); savedMember = memberRepository.save(new Member("shook@wooteco.com", "shook")); refreshToken = tokenProvider.createRefreshToken(savedMember.getId(), savedMember.getNickname()); accessToken = tokenProvider.createAccessToken(savedMember.getId(), savedMember.getNickname()); @@ -90,60 +90,5 @@ void success_google_login() { assertThat(accessTokenClaims.get("memberId", Long.class)).isEqualTo(savedMember.getId()); assertThat(accessTokenClaims.get("nickname", String.class)).isEqualTo(savedMember.getNickname()); assertThat(refreshTokenClaims.get("memberId", Long.class)).isEqualTo(savedMember.getId()); - assertThat(refreshTokenClaims.get("nickname", String.class)).isEqualTo(savedMember.getNickname()); - } - - @DisplayName("올바른 refresh 토큰과 access 토큰이 들어오면 access 토큰을 재발급해준다.") - @Test - void success_reissue() { - //given - //when - final ReissueAccessTokenResponse result = authService.reissueAccessTokenByRefreshToken( - refreshToken, accessToken); - - //then - final String accessToken = tokenProvider.createAccessToken( - savedMember.getId(), - savedMember.getNickname()); - - assertThat(result.getAccessToken()).isEqualTo(accessToken); - } - - @DisplayName("잘못된 refresh 토큰(secret Key가 다른)이 들어오면 예외를 던진다.") - @Test - void fail_reissue_invalid_refreshToken() { - //given - final TokenProvider inValidTokenProvider = new TokenProvider( - 10L, - 100L, - "asdzzxcwetg2adfvssd3xZcZXCZvzx"); - - final String wrongRefreshToken = inValidTokenProvider.createRefreshToken( - savedMember.getId(), - savedMember.getNickname()); - - //when - //then - assertThatThrownBy(() -> authService.reissueAccessTokenByRefreshToken(wrongRefreshToken, accessToken)) - .isInstanceOf(TokenException.NotIssuedTokenException.class); - } - - @DisplayName("기간이 만료된 refresh 토큰이면 예외를 던진다.") - @Test - void fail_reissue_expired_refreshToken() { - //given - final TokenProvider inValidTokenProvider = new TokenProvider( - 0, - 0, - "asdfsdsvsdf2esvsdvsdvs23"); - - final String refreshToken = inValidTokenProvider.createRefreshToken( - savedMember.getId(), - savedMember.getNickname()); - - //when - //then - assertThatThrownBy(() -> authService.reissueAccessTokenByRefreshToken(refreshToken, accessToken)) - .isInstanceOf(TokenException.ExpiredTokenException.class); } } diff --git a/backend/src/test/java/shook/shook/auth/application/TokenProviderTest.java b/backend/src/test/java/shook/shook/auth/application/TokenProviderTest.java index e8c1a5965..d2b2d9ee3 100644 --- a/backend/src/test/java/shook/shook/auth/application/TokenProviderTest.java +++ b/backend/src/test/java/shook/shook/auth/application/TokenProviderTest.java @@ -19,8 +19,8 @@ class TokenProviderTest { @BeforeEach public void setUp() { tokenProvider = new TokenProvider(ACCESS_TOKEN_VALID_TIME, - REFRESH_TOKEN_VALID_TIME, - SECRET_CODE); + REFRESH_TOKEN_VALID_TIME, + SECRET_CODE); } @DisplayName("올바른 access token을 생성한다.") diff --git a/backend/src/test/java/shook/shook/auth/application/TokenServiceTest.java b/backend/src/test/java/shook/shook/auth/application/TokenServiceTest.java new file mode 100644 index 000000000..a181a87e7 --- /dev/null +++ b/backend/src/test/java/shook/shook/auth/application/TokenServiceTest.java @@ -0,0 +1,99 @@ +package shook.shook.auth.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import shook.shook.auth.application.dto.ReissueAccessTokenResponse; +import shook.shook.auth.exception.TokenException; +import shook.shook.auth.repository.InMemoryTokenPairRepository; +import shook.shook.member.domain.Member; +import shook.shook.member.domain.repository.MemberRepository; + +@SpringBootTest +class TokenServiceTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private InMemoryTokenPairRepository inMemoryTokenPairRepository; + + private TokenProvider tokenProvider; + private TokenService tokenService; + private Member savedMember; + private String refreshToken; + private String accessToken; + + @BeforeEach + void setUp() { + tokenProvider = new TokenProvider( + 100000L, + 1000000L, + "asdfsdsvsdf2esvsdvsdvs23"); + tokenService = new TokenService(tokenProvider, inMemoryTokenPairRepository); + savedMember = memberRepository.save(new Member("shook@wooteco.com", "shook")); + refreshToken = tokenProvider.createRefreshToken(savedMember.getId(), savedMember.getNickname()); + accessToken = tokenProvider.createAccessToken(savedMember.getId(), savedMember.getNickname()); + inMemoryTokenPairRepository.addOrUpdateTokenPair(refreshToken, accessToken); + } + + @DisplayName("올바른 refresh 토큰과 access 토큰이 들어오면 access 토큰을 재발급해준다.") + @Test + void success_reissue() { + //given + //when + final ReissueAccessTokenResponse result = tokenService.reissueAccessTokenByRefreshToken(refreshToken, + accessToken); + + //then + final String newAccessToken = result.getAccessToken(); + final Claims accessTokenClaimsBeforeCreation = tokenProvider.parseClaims(accessToken); + final Claims newAccessTokenClaims = tokenProvider.parseClaims(newAccessToken); + + assertThat(newAccessTokenClaims.get("memberId")).isEqualTo(accessTokenClaimsBeforeCreation.get("memberId")); + assertThat(newAccessTokenClaims.get("nickname")).isEqualTo(accessTokenClaimsBeforeCreation.get("nickname")); + } + + @DisplayName("잘못된 refresh 토큰(secret Key가 다른)이 들어오면 예외를 던진다.") + @Test + void fail_reissue_invalid_refreshToken() { + //given + final TokenProvider inValidTokenProvider = new TokenProvider( + 10L, + 100L, + "asdzzxcwetg2adfvssd3xZcZXCZvzx"); + + final String wrongRefreshToken = inValidTokenProvider.createRefreshToken(savedMember.getId(), + savedMember.getNickname()); + + //when + //then + assertThatThrownBy(() -> tokenService.reissueAccessTokenByRefreshToken(wrongRefreshToken, accessToken)) + .isInstanceOf(TokenException.RefreshTokenNotFoundException.class); + } + + @DisplayName("기간이 만료된 refresh 토큰이면 예외를 던진다.") + @Test + void fail_reissue_expired_refreshToken() { + //given + final TokenProvider inValidTokenProvider = new TokenProvider( + 0, + 0, + "asdfsdsvsdf2esvsdvsdvs23"); + + final String refreshToken = inValidTokenProvider.createRefreshToken(savedMember.getId(), + savedMember.getNickname()); + inMemoryTokenPairRepository.addOrUpdateTokenPair(refreshToken, accessToken); + + //when + //then + assertThatThrownBy(() -> tokenService.reissueAccessTokenByRefreshToken(refreshToken, accessToken)) + .isInstanceOf(TokenException.ExpiredTokenException.class); + } +} diff --git a/backend/src/test/java/shook/shook/member/application/MemberServiceTest.java b/backend/src/test/java/shook/shook/member/application/MemberServiceTest.java index d6e30d473..6124219e3 100644 --- a/backend/src/test/java/shook/shook/member/application/MemberServiceTest.java +++ b/backend/src/test/java/shook/shook/member/application/MemberServiceTest.java @@ -5,12 +5,16 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.jdbc.Sql; +import shook.shook.auth.application.TokenProvider; import shook.shook.auth.exception.AuthorizationException; -import shook.shook.auth.ui.Authority; -import shook.shook.auth.ui.argumentresolver.MemberInfo; +import shook.shook.auth.repository.InMemoryTokenPairRepository; +import shook.shook.member.application.dto.NicknameUpdateRequest; import shook.shook.member.domain.Member; import shook.shook.member.domain.Nickname; import shook.shook.member.domain.repository.MemberRepository; @@ -27,6 +31,9 @@ class MemberServiceTest extends UsingJpaTest { private static Member savedMember; + private static final long ACCESS_TOKEN_VALID_TIME = 12000L; + private static final long REFRESH_TOKEN_VALID_TIME = 6048000L; + private static final String SECRET_CODE = "2345asdfasdfsadfsdf243dfdsfsfs"; @Autowired private MemberRepository memberRepository; @@ -40,11 +47,17 @@ class MemberServiceTest extends UsingJpaTest { @Autowired private KillingPartLikeRepository likeRepository; + private TokenProvider tokenProvider; + private MemberService memberService; @BeforeEach void setUp() { - memberService = new MemberService(memberRepository, partCommentRepository, likeRepository); + tokenProvider = new TokenProvider(ACCESS_TOKEN_VALID_TIME, + REFRESH_TOKEN_VALID_TIME, + SECRET_CODE); + memberService = new MemberService(memberRepository, partCommentRepository, likeRepository, tokenProvider, + new InMemoryTokenPairRepository()); savedMember = memberRepository.save(new Member("woowa@wooteco.com", "shook")); } @@ -112,7 +125,7 @@ void fail_findByIdAndNickname_wrong_nickname() { //then assertThatThrownBy( () -> memberService.findByIdAndNicknameThrowIfNotExist(savedMember.getId(), - new Nickname(savedMember.getNickname() + "none"))) + new Nickname(savedMember.getNickname() + "none"))) .isInstanceOf(MemberException.MemberNotExistException.class); } @@ -124,7 +137,7 @@ void fail_findByIdAndNickname_wrong_memberId() { //then assertThatThrownBy( () -> memberService.findByIdAndNicknameThrowIfNotExist(savedMember.getId() + 1, - new Nickname(savedMember.getNickname()))) + new Nickname(savedMember.getNickname()))) .isInstanceOf(MemberException.MemberNotExistException.class); } @@ -136,7 +149,7 @@ void fail_findByIdAndNickname_wrong_memberId_and_nickname() { //then assertThatThrownBy( () -> memberService.findByIdAndNicknameThrowIfNotExist(savedMember.getId() + 1, - new Nickname(savedMember.getNickname() + "none"))) + new Nickname(savedMember.getNickname() + "none"))) .isInstanceOf(MemberException.MemberNotExistException.class); } @@ -152,7 +165,7 @@ void success_delete() { saveAndClearEntityManager(); // when - memberService.deleteById(targetId, new MemberInfo(targetId, Authority.MEMBER)); + memberService.deleteById(targetId, targetId); // then assertThat(likeRepository.findAllByMemberAndIsDeleted(savedMember, false)).isEmpty(); @@ -169,7 +182,7 @@ void fail_delete() { // when, then assertThatThrownBy(() -> - memberService.deleteById(targetId, new MemberInfo(unsavedMemberId, Authority.MEMBER)) + memberService.deleteById(targetId, targetId) ).isInstanceOf(MemberException.MemberNotExistException.class); } @@ -182,8 +195,79 @@ void fail_delete_unauthenticated() { // when, then assertThatThrownBy(() -> - memberService.deleteById(targetMember.getId(), - new MemberInfo(requestMember.getId(), Authority.MEMBER)) + memberService.deleteById(targetMember.getId(), + requestMember.getId()) ).isInstanceOf(AuthorizationException.UnauthenticatedException.class); } + + @DisplayName("닉네임을 변경한다.") + @Nested + class NicknameUpdate { + + @DisplayName("변경할 닉네임으로 닉네임을 변경한다.") + @ValueSource(strings = {"newNickname", "newNickname123", "newNickname1234", "한글도20자를닉네임으로사용할수있습니다"}) + @ParameterizedTest + void success_update(final String newNickname) { + // given + final NicknameUpdateRequest request = new NicknameUpdateRequest(newNickname); + + // when + memberService.updateNickname(savedMember.getId(), savedMember.getId(), request); + + // then + assertThat(memberRepository.findById(savedMember.getId()).get().getNickname()) + .isEqualTo(newNickname); + } + + @DisplayName("기존 닉네임과 동일한 닉네임으로 변경하는 경우, false 를 리턴한다.") + @Test + void success_updateNickname_same_nickname_before() { + // given + final NicknameUpdateRequest request = new NicknameUpdateRequest(savedMember.getNickname()); + + // when + // then + assertThat(memberService.updateNickname(savedMember.getId(), savedMember.getId(), request)).isFalse(); + } + + @DisplayName("변경할 닉네임이 중복되면 예외를 던진다.") + @Test + void fail_updateNickname_duplicate_nickname() { + // given + final Member newMember = memberRepository.save(new Member("temp@email", "shook2")); + final String duplicateNickname = "shook"; + final NicknameUpdateRequest request = new NicknameUpdateRequest(duplicateNickname); + + // when + // then + assertThatThrownBy(() -> memberService.updateNickname(newMember.getId(), newMember.getId(), request)) + .isInstanceOf(MemberException.ExistNicknameException.class); + } + + @DisplayName("닉네임이 빈 문자열이면 예외를 던진다.") + @ValueSource(strings = {"", " ", " ", "\r", "\n", "\t"}) + @ParameterizedTest + void fail_updateNickname_empty_nickname(final String emptyValue) { + // given + final NicknameUpdateRequest request = new NicknameUpdateRequest(emptyValue); + + // when + // then + assertThatThrownBy(() -> memberService.updateNickname(savedMember.getId(), savedMember.getId(), request)) + .isInstanceOf(MemberException.NullOrEmptyNicknameException.class); + } + + @DisplayName("변경할 닉네임 길이가 20자를 초과하면 예외를 던진다.") + @ValueSource(strings = {"veryverylonglonglongnickname", "123456789012345678901", "입력한닉네임이너무길어서업데이트할수없어요"}) + @ParameterizedTest + void fail_updateNickname_too_long_nickname(final String tooLongNickname) { + // given + final NicknameUpdateRequest request = new NicknameUpdateRequest(tooLongNickname); + + // when + // then + assertThatThrownBy(() -> memberService.updateNickname(savedMember.getId(), savedMember.getId(), request)) + .isInstanceOf(MemberException.TooLongNicknameException.class); + } + } } diff --git a/backend/src/test/java/shook/shook/member/domain/NicknameTest.java b/backend/src/test/java/shook/shook/member/domain/NicknameTest.java index 169573959..3ac43b32f 100644 --- a/backend/src/test/java/shook/shook/member/domain/NicknameTest.java +++ b/backend/src/test/java/shook/shook/member/domain/NicknameTest.java @@ -33,11 +33,11 @@ void create_fail_lessThanOne(final String nickname) { .isInstanceOf(MemberException.NullOrEmptyNicknameException.class); } - @DisplayName("닉네임의 길이가 100자를 넘을 경우 예외를 던진다.") + @DisplayName("닉네임의 길이가 20자를 넘을 경우 예외를 던진다.") @Test - void create_fail_lengthOver100() { + void create_fail_lengthOver20() { //given - final String nickname = ".".repeat(101); + final String nickname = ".".repeat(21); //when //then diff --git a/backend/src/test/java/shook/shook/member/ui/MemberControllerTest.java b/backend/src/test/java/shook/shook/member/ui/MemberControllerTest.java index 21329046d..1d5e6650d 100644 --- a/backend/src/test/java/shook/shook/member/ui/MemberControllerTest.java +++ b/backend/src/test/java/shook/shook/member/ui/MemberControllerTest.java @@ -1,12 +1,16 @@ package shook.shook.member.ui; import io.restassured.RestAssured; +import io.restassured.http.ContentType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import shook.shook.auth.application.TokenProvider; +import shook.shook.member.application.dto.NicknameUpdateRequest; import shook.shook.member.domain.Member; import shook.shook.member.domain.repository.MemberRepository; import shook.shook.support.AcceptanceTest; @@ -25,12 +29,12 @@ void deleteMember() { // given final Member member = memberRepository.save(new Member("hi@naver.com", "hi")); final String accessToken = tokenProvider.createAccessToken(member.getId(), - member.getNickname()); + member.getNickname()); // when, then RestAssured.given().log().all() - .when().log().all() .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() .delete("/members/{member_id}", member.getId()) .then().log().all() .statusCode(HttpStatus.NO_CONTENT.value()); @@ -43,14 +47,81 @@ void deleteMember_forbidden() { final Member member = memberRepository.save(new Member("hi@naver.com", "hi")); final Member requestMember = memberRepository.save(new Member("new@naver.com", "new")); final String accessToken = tokenProvider.createAccessToken(requestMember.getId(), - requestMember.getNickname()); + requestMember.getNickname()); // when, then RestAssured.given().log().all() - .when().log().all() .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() .delete("/members/{member_id}", member.getId()) .then().log().all() .statusCode(HttpStatus.FORBIDDEN.value()); } + + @DisplayName("닉네임 수정 시 200 상태 코드와 새로운 토큰이 반환된다.") + @Test + void updateNickname_OK() { + // given + final Member member = memberRepository.save(new Member("hi@naver.com", "hi")); + final String accessToken = tokenProvider.createAccessToken(member.getId(), member.getNickname()); + + final NicknameUpdateRequest request = new NicknameUpdateRequest("newNickname"); + + // when + // then + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .contentType(ContentType.JSON) + .body(request) + .when().log().all() + .patch("/members/{member_id}/nickname", member.getId()) + .then().log().all() + .statusCode(HttpStatus.OK.value()); + } + + @DisplayName("동일한 닉네임으로 수정 시 204 상태 코드가 반환된다.") + @Test + void updateNickname_noContent() { + // given + final Member member = memberRepository.save(new Member("hi@naver.com", "nickname")); + final String accessToken = tokenProvider.createAccessToken(member.getId(), + member.getNickname()); + + final NicknameUpdateRequest request = new NicknameUpdateRequest("nickname"); + + // when + // then + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .contentType(ContentType.JSON) + .body(request) + .when().log().all() + .patch("/members/{member_id}/nickname", member.getId()) + .then().log().all() + .statusCode(HttpStatus.NO_CONTENT.value()); + } + + @DisplayName("유효하지 않은 닉네임으로 닉네임 수정 시도 시 400 상태코드가 반환된다.") + @ValueSource(strings = {"", " ", " ", "hi", "닉네임이너무너무너무너무너무너무너무길어요"}) + @ParameterizedTest + void updateNickname_badRequest(final String invalidNickname) { + // given + final Member member = memberRepository.save(new Member("hi@naver.com", "hi")); + final Member newMember = memberRepository.save(new Member("new@naver.com", "new")); + final String accessToken = tokenProvider.createAccessToken(newMember.getId(), + newMember.getNickname()); + + final NicknameUpdateRequest request = new NicknameUpdateRequest(invalidNickname); + + // when + // then + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .contentType(ContentType.JSON) + .body(request) + .when().log().all() + .patch("/members/{member_id}/nickname", newMember.getId()) + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } }