diff --git a/src/main/java/com/ject/studytrip/auth/application/dto/OAuthLoginOutcome.java b/src/main/java/com/ject/studytrip/auth/application/dto/OAuthLoginOutcome.java new file mode 100644 index 0000000..40943ec --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/application/dto/OAuthLoginOutcome.java @@ -0,0 +1,24 @@ +package com.ject.studytrip.auth.application.dto; + +public record OAuthLoginOutcome(Outcome outcome, TokenInfo tokenInfo, String signupKey) { + enum Outcome { + SUCCESS, + SIGNUP_REQUIRED + } + + public static OAuthLoginOutcome success( + String accessToken, String refreshToken, long refreshTokenExpiresIn) { + return new OAuthLoginOutcome( + Outcome.SUCCESS, + new TokenInfo(accessToken, refreshToken, refreshTokenExpiresIn), + null); + } + + public static OAuthLoginOutcome signupRequired(String signupKey) { + return new OAuthLoginOutcome(Outcome.SIGNUP_REQUIRED, null, signupKey); + } + + public boolean isSignupRequired() { + return this.outcome == Outcome.SIGNUP_REQUIRED; + } +} diff --git a/src/main/java/com/ject/studytrip/auth/application/dto/TokenInfo.java b/src/main/java/com/ject/studytrip/auth/application/dto/TokenInfo.java new file mode 100644 index 0000000..d9b500d --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/application/dto/TokenInfo.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.auth.application.dto; + +public record TokenInfo(String accessToken, String refreshToken, long refreshTokenExpiresIn) { + public static TokenInfo of( + String accessToken, String refreshToken, long refreshTokenExpiresIn) { + return new TokenInfo(accessToken, refreshToken, refreshTokenExpiresIn); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java b/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java index 03ec717..e23bfa4 100644 --- a/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java +++ b/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java @@ -1,13 +1,15 @@ package com.ject.studytrip.auth.application.facade; +import com.ject.studytrip.auth.application.dto.OAuthLoginOutcome; +import com.ject.studytrip.auth.application.dto.TokenInfo; import com.ject.studytrip.auth.application.service.KakaoLoginService; +import com.ject.studytrip.auth.application.service.KakaoSignupProfileService; import com.ject.studytrip.auth.application.service.TokenService; +import com.ject.studytrip.auth.domain.model.KakaoSignupProfile; import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; import com.ject.studytrip.auth.presentation.dto.request.LogoutRequest; -import com.ject.studytrip.auth.presentation.dto.request.TokenReissueRequest; -import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; import com.ject.studytrip.member.application.dto.CreateMemberCommand; import com.ject.studytrip.member.application.service.MemberService; import com.ject.studytrip.member.domain.model.Member; @@ -19,42 +21,65 @@ @RequiredArgsConstructor public class AuthFacade { private final KakaoLoginService kakaoLoginService; + private final KakaoSignupProfileService kakaoSignupProfileService; private final TokenService tokenService; private final MemberService memberService; - public TokenResponse kakaoLogin(KakaoLoginRequest request, String origin) { - KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code(), origin); + public OAuthLoginOutcome kakaoLogin(KakaoLoginRequest request, String origin) { + KakaoUserInfoResponse info = kakaoLoginService.getKakaoUserInfo(request.code(), origin); - Member member = - memberService.getMemberBySocialProviderAndSocialId( - SocialProvider.KAKAO, response.kakaoId()); - - return tokenService.getTokens(member.getId().toString(), member.getRole().name()); + return memberService + .getMemberBySocialProviderAndSocialId(SocialProvider.KAKAO, info.kakaoId()) + // 가입되어 있는 사용자인 경우 토큰 발급 + .map( + member -> + createLoginOutcomeWithIssuedTokens( + member.getId(), member.getRole().name())) + // 가입이 필요할 경우 가입 키 발급 + .orElseGet(() -> createSignupRequiredOutcomeWithIssuedSignupKey(info)); } - public TokenResponse kakaoSignup(KakaoSignupRequest request, String origin) { - KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code(), origin); + public TokenInfo kakaoSignup(String signupKey, KakaoSignupRequest request) { + KakaoSignupProfile profile = kakaoSignupProfileService.getSignupProfileByKey(signupKey); CreateMemberCommand command = CreateMemberCommand.of( - response.kakaoId(), - response.getEmail(), - response.getProfileImage(), + profile.socialId(), + profile.email(), + profile.profileImageUrl(), request.nickname(), request.category()); Member member = memberService.createMemberFromKakao(command); + kakaoSignupProfileService.deleteBySignupKey(signupKey); + return tokenService.getTokens(member.getId().toString(), member.getRole().name()); } - public TokenResponse reissueToken(TokenReissueRequest request) { - String memberId = tokenService.getMemberIdByRefreshToken(request.refreshToken()); + public TokenInfo reissueToken(String refreshToken) { + String memberId = tokenService.getMemberIdByRefreshToken(refreshToken); String role = memberService.getRoleByMemberId(memberId); - return tokenService.reissueToken(request.refreshToken(), memberId, role); + return tokenService.reissueToken(refreshToken, memberId, role); + } + + public void logout(LogoutRequest request, String refreshToken) { + tokenService.logout(request.accessToken(), refreshToken); + } + + private OAuthLoginOutcome createLoginOutcomeWithIssuedTokens(Long memberId, String roleName) { + // 토큰 발급 + TokenInfo tokens = tokenService.getTokens(memberId.toString(), roleName); + return OAuthLoginOutcome.success( + tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenExpiresIn()); } - public void logout(LogoutRequest request) { - tokenService.logout(request.accessToken(), request.refreshToken()); + private OAuthLoginOutcome createSignupRequiredOutcomeWithIssuedSignupKey( + KakaoUserInfoResponse info) { + // 카카오 가입 프로필 임시 저장 및 키 발급 + String signupKey = + kakaoSignupProfileService.saveAndIssueSignupKey( + info.kakaoId(), info.getEmail(), info.getProfileImage()); + return OAuthLoginOutcome.signupRequired(signupKey); } } diff --git a/src/main/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileService.java b/src/main/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileService.java new file mode 100644 index 0000000..17c4ede --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileService.java @@ -0,0 +1,33 @@ +package com.ject.studytrip.auth.application.service; + +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.domain.model.KakaoSignupProfile; +import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository; +import com.ject.studytrip.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KakaoSignupProfileService { + private final KakaoSignupProfileRedisRepository kakaoSignupProfileRedisRepository; + + public String saveAndIssueSignupKey(String socialId, String email, String profileImageUrl) { + return kakaoSignupProfileRedisRepository.saveAndIssueSignupKey( + socialId, email, profileImageUrl); + } + + public KakaoSignupProfile getSignupProfileByKey(String signupKey) { + if (signupKey == null || signupKey.isBlank()) { + throw new CustomException(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY); + } + + return kakaoSignupProfileRedisRepository + .findBySignupKey(signupKey) + .orElseThrow(() -> new CustomException(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY)); + } + + public void deleteBySignupKey(String signupKey) { + kakaoSignupProfileRedisRepository.deleteBySignupKey(signupKey); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/application/service/TokenService.java b/src/main/java/com/ject/studytrip/auth/application/service/TokenService.java index a305936..fe76f25 100644 --- a/src/main/java/com/ject/studytrip/auth/application/service/TokenService.java +++ b/src/main/java/com/ject/studytrip/auth/application/service/TokenService.java @@ -1,10 +1,10 @@ package com.ject.studytrip.auth.application.service; +import com.ject.studytrip.auth.application.dto.TokenInfo; import com.ject.studytrip.auth.domain.error.AuthErrorCode; import com.ject.studytrip.auth.domain.repository.LogoutTokenRedisRepository; import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository; import com.ject.studytrip.auth.infra.provider.TokenProvider; -import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; import com.ject.studytrip.global.exception.CustomException; import java.util.List; import lombok.RequiredArgsConstructor; @@ -20,7 +20,7 @@ public class TokenService { private final LogoutTokenRedisRepository logoutTokenRedisRepository; private final RefreshTokenRedisRepository refreshTokenRedisRepository; - public TokenResponse getTokens(String memberId, String role) { + public TokenInfo getTokens(String memberId, String role) { String accessToken = tokenProvider.createAccessToken(memberId, role); String refreshToken = tokenProvider.createRefreshToken(); long refreshTokenExpirationTime = tokenProvider.getRefreshTokenExpirationTime(); @@ -28,10 +28,10 @@ public TokenResponse getTokens(String memberId, String role) { refreshTokenRedisRepository.saveRefreshToken( memberId, refreshToken, refreshTokenExpirationTime); - return TokenResponse.of(accessToken, refreshToken); + return TokenInfo.of(accessToken, refreshToken, refreshTokenExpirationTime); } - public TokenResponse reissueToken(String refreshToken, String memberId, String role) { + public TokenInfo reissueToken(String refreshToken, String memberId, String role) { long refreshTokenExpirationTime = tokenProvider.getRefreshTokenExpirationTime(); String newAccessToken = tokenProvider.createAccessToken(memberId, role); String newRefreshToken = tokenProvider.createRefreshToken(); @@ -40,7 +40,7 @@ public TokenResponse reissueToken(String refreshToken, String memberId, String r refreshTokenRedisRepository.saveRefreshToken( memberId, newRefreshToken, refreshTokenExpirationTime); - return TokenResponse.of(newAccessToken, newRefreshToken); + return TokenInfo.of(newAccessToken, newRefreshToken, refreshTokenExpirationTime); } public void logout(String accessToken, String refreshToken) { @@ -77,6 +77,10 @@ public void validateActiveAccessToken(String accessToken) { } private void validateRefreshToken(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new CustomException(AuthErrorCode.MISSING_REFRESH_TOKEN); + } + if (!refreshTokenRedisRepository.existsRefreshToken(refreshToken)) { throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); } diff --git a/src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java b/src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java index 6f8226a..29b7a50 100644 --- a/src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java +++ b/src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java @@ -15,9 +15,13 @@ public enum AuthErrorCode implements ErrorCode { INVALID_KAKAO_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 카카오 액세스 토큰입니다."), INVALID_KAKAO_AUTHORIZATION_CODE(HttpStatus.BAD_REQUEST, "잘못된 카카오 인가 코드입니다."), KAKAO_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "카카오 서버에서 오류가 발생했습니다."), + MISSING_KAKAO_SIGNUP_KEY(HttpStatus.BAD_REQUEST, "카카오 가입 키(signupKey)가 누락되었습니다."), + INVALID_KAKAO_SIGNUP_KEY( + HttpStatus.BAD_REQUEST, "요청한 카카오 가입 키(signupKey)의 정보가 존재하지 않거나 만료되었습니다."), // 인증 관련 예외 INVALID_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 JWT 토큰입니다."), + MISSING_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "리프레시 토큰이 누락되었습니다."), INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 리프레시 토큰입니다."), TOKEN_IS_BLACKLISTED(HttpStatus.UNAUTHORIZED, "블랙리스트된 엑세스 토큰입니다."), ; diff --git a/src/main/java/com/ject/studytrip/auth/domain/model/KakaoSignupProfile.java b/src/main/java/com/ject/studytrip/auth/domain/model/KakaoSignupProfile.java new file mode 100644 index 0000000..6d29cb7 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/domain/model/KakaoSignupProfile.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.auth.domain.model; + +public record KakaoSignupProfile( + String socialId, String socialProvider, String email, String profileImageUrl) { + public static KakaoSignupProfile of( + String socialId, String socialProvider, String email, String profileImageUrl) { + return new KakaoSignupProfile(socialId, socialProvider, email, profileImageUrl); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/domain/repository/KakaoSignupProfileRedisRepository.java b/src/main/java/com/ject/studytrip/auth/domain/repository/KakaoSignupProfileRedisRepository.java new file mode 100644 index 0000000..47d6ca0 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/domain/repository/KakaoSignupProfileRedisRepository.java @@ -0,0 +1,12 @@ +package com.ject.studytrip.auth.domain.repository; + +import com.ject.studytrip.auth.domain.model.KakaoSignupProfile; +import java.util.Optional; + +public interface KakaoSignupProfileRedisRepository { + String saveAndIssueSignupKey(String socialId, String email, String profileImageUrl); + + Optional findBySignupKey(String signupKey); + + void deleteBySignupKey(String signupKey); +} diff --git a/src/main/java/com/ject/studytrip/auth/infra/repository/redis/KakaoSignupProfileRedisRepositoryAdapter.java b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/KakaoSignupProfileRedisRepositoryAdapter.java new file mode 100644 index 0000000..34a1f87 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/KakaoSignupProfileRedisRepositoryAdapter.java @@ -0,0 +1,52 @@ +package com.ject.studytrip.auth.infra.repository.redis; + +import static com.ject.studytrip.global.common.constants.CacheKeyConstants.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ject.studytrip.auth.domain.model.KakaoSignupProfile; +import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository; +import com.ject.studytrip.member.domain.model.SocialProvider; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class KakaoSignupProfileRedisRepositoryAdapter implements KakaoSignupProfileRedisRepository { + private static final long KAKAO_SIGNUP_PROFILE_TTL_MILLIS = 900000; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public String saveAndIssueSignupKey(String socialId, String email, String profileImageUrl) { + String socialProvider = SocialProvider.KAKAO.name().toLowerCase(); + String key = issueKey(socialProvider); + KakaoSignupProfile signupProfile = + KakaoSignupProfile.of(socialId, socialProvider, email, profileImageUrl); + + redisTemplate + .opsForValue() + .set(key, signupProfile, KAKAO_SIGNUP_PROFILE_TTL_MILLIS, TimeUnit.MILLISECONDS); + + return key; + } + + @Override + public Optional findBySignupKey(String signupKey) { + return Optional.ofNullable(redisTemplate.opsForValue().get(signupKey)) + .map(value -> objectMapper.convertValue(value, KakaoSignupProfile.class)); + } + + @Override + public void deleteBySignupKey(String signupKey) { + redisTemplate.delete(signupKey); + } + + private String issueKey(String socialProvider) { + return OAUTH_SIGNUP_PROFILE_PREFIX.formatted(socialProvider) + UUID.randomUUID(); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.java b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.java index fb018d3..c06f13b 100644 --- a/src/main/java/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.java @@ -16,15 +16,11 @@ public class LogoutTokenRedisRepositoryAdapter implements LogoutTokenRedisReposi public void saveAccessToken(String accessToken, long accessTokenExpirationTime) { redisTemplate .opsForValue() - .set( - AUTH_LOGOUT_TOKEN_PREFIX.getValue() + accessToken, - "LOGOUT", - accessTokenExpirationTime); + .set(AUTH_LOGOUT_TOKEN_PREFIX + accessToken, "LOGOUT", accessTokenExpirationTime); } @Override public boolean existsAccessToken(String accessToken) { - return Boolean.TRUE.equals( - redisTemplate.hasKey(AUTH_LOGOUT_TOKEN_PREFIX.getValue() + accessToken)); + return Boolean.TRUE.equals(redisTemplate.hasKey(AUTH_LOGOUT_TOKEN_PREFIX + accessToken)); } } diff --git a/src/main/java/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.java b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.java index 803ca29..ce14b6c 100644 --- a/src/main/java/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.java @@ -19,7 +19,7 @@ public void saveRefreshToken( redisTemplate .opsForValue() .set( - AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken, + AUTH_REISSUE_TOKEN_PREFIX + refreshToken, memberId, refreshTokenExpireTime, TimeUnit.MILLISECONDS); @@ -27,17 +27,16 @@ public void saveRefreshToken( @Override public boolean existsRefreshToken(String refreshToken) { - return Boolean.TRUE.equals( - redisTemplate.hasKey(AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken)); + return Boolean.TRUE.equals(redisTemplate.hasKey(AUTH_REISSUE_TOKEN_PREFIX + refreshToken)); } @Override public void deleteRefreshToken(String refreshToken) { - redisTemplate.delete(AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken); + redisTemplate.delete(AUTH_REISSUE_TOKEN_PREFIX + refreshToken); } @Override public String findMemberIdByRefreshToken(String refreshToken) { - return redisTemplate.opsForValue().get(AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken); + return redisTemplate.opsForValue().get(AUTH_REISSUE_TOKEN_PREFIX + refreshToken); } } diff --git a/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java b/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java index 85fec52..0bfa018 100644 --- a/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java +++ b/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java @@ -1,17 +1,24 @@ package com.ject.studytrip.auth.presentation.controller; +import static com.ject.studytrip.global.common.constants.CookieConstants.*; + +import com.ject.studytrip.auth.application.dto.OAuthLoginOutcome; +import com.ject.studytrip.auth.application.dto.TokenInfo; import com.ject.studytrip.auth.application.facade.AuthFacade; import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; import com.ject.studytrip.auth.presentation.dto.request.LogoutRequest; -import com.ject.studytrip.auth.presentation.dto.request.TokenReissueRequest; -import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; +import com.ject.studytrip.auth.presentation.dto.response.LoginResponse; +import com.ject.studytrip.auth.presentation.dto.response.ReissueTokenResponse; +import com.ject.studytrip.auth.presentation.helper.AuthCookieHelper; import com.ject.studytrip.global.common.response.StandardResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -22,44 +29,109 @@ public class AuthController { private final AuthFacade authFacade; - @Operation(summary = "카카오 로그인", description = "카카오 인가 코드를 이용하여, 엑세스 토큰과 리프레시 토큰을 발급합니다.") + @Operation( + summary = "카카오 로그인", + description = + """ + 카카오 인가 코드를 이용하여 로그인을 수행합니다. + 가입된 회원이라면 엑세스 토큰(Response Body)과 리프레시 토큰(HttpOnly Secure 쿠키 - 'auth_refresh')을 반환합니다. + 가입되지 않은 회원이라면 OAuth 가입키('oauth_signup_key')를 HttpOnly Secure 쿠키에 담아 응답합니다. + """) @PostMapping("/login/kakao") public ResponseEntity kakaoLogin( @RequestAttribute(value = "origin") String origin, @Valid @RequestBody KakaoLoginRequest request) { - TokenResponse response = authFacade.kakaoLogin(request, origin); + OAuthLoginOutcome response = authFacade.kakaoLogin(request, origin); - return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), response)); + // 회원가입이 필요할 경우 + // 카카오 유저 프로필 키 저장 (쿠키) + if (response.isSignupRequired()) { + ResponseCookie pendingCookie = + AuthCookieHelper.setOAuthSignupProfileCookie(response.signupKey()); + return ResponseEntity.status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, pendingCookie.toString()) + .body( + StandardResponse.success( + HttpStatus.OK.value(), LoginResponse.requiredSignup())); + } + + // 리프레시 토큰 저장 (쿠키) + ResponseCookie refreshCookie = + AuthCookieHelper.setRefreshTokenCookie( + response.tokenInfo().refreshToken(), + response.tokenInfo().refreshTokenExpiresIn()); + return ResponseEntity.status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + LoginResponse.success(response.tokenInfo().accessToken()))); } @Operation( summary = "카카오 회원가입", - description = "카카오 인가 코드, 카테고리, 닉네임을 이용하여, 엑세스 토큰과 리프레시 토큰을 발급합니다.") + description = + """ + 닉네임, 카테고리를 입력받고, OAuth 가입키 쿠키로 회원가입을 수행합니다. + 회원가입에 성공하면 로그인처리되며, 엑세스 토큰(Response Body)과 리프레시 토큰(HttpOnly Secure 쿠키 - 'auth_refresh')을 반환합니다. + """) @PostMapping("/signup/kakao") public ResponseEntity kakaoSignup( - @RequestAttribute(value = "origin") String origin, + @CookieValue(name = OAUTH_SIGNUP_KEY, required = false) String pendingKey, @Valid @RequestBody KakaoSignupRequest request) { - TokenResponse response = authFacade.kakaoSignup(request, origin); + TokenInfo response = authFacade.kakaoSignup(pendingKey, request); - return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), response)); + // 리프레시 토큰 쿠키 생성 + ResponseCookie refreshCookie = + AuthCookieHelper.setRefreshTokenCookie( + response.refreshToken(), response.refreshTokenExpiresIn()); + + // 카카오 가입 쿠키 삭제 + ResponseCookie clearSignupCookie = AuthCookieHelper.clearCookie(OAUTH_SIGNUP_KEY); + + return ResponseEntity.status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .header(HttpHeaders.SET_COOKIE, clearSignupCookie.toString()) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + LoginResponse.success(response.accessToken()))); } - @Operation(summary = "토큰 재발급", description = "리프레시 토큰을 이용하여, 엑세스 토큰과 리프레시 토큰을 재발급합니다.") + @Operation( + summary = "토큰 재발급", + description = + "리프레시 토큰을 이용하여, 엑세스 토큰과 리프레시 토큰을 재발급합니다. 리프레시 토큰은 'auth_refresh' HttpOnly Secure 쿠키에 담아 응답합니다.") @PostMapping("/token/reissue") public ResponseEntity reissueToken( - @Valid @RequestBody TokenReissueRequest request) { - TokenResponse response = authFacade.reissueToken(request); + @CookieValue(name = AUTH_REFRESH_TOKEN, required = false) String refreshToken) { + TokenInfo response = authFacade.reissueToken(refreshToken); - return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), response)); + // 리프레시 토큰 덮어쓰기 + ResponseCookie refreshCookie = + AuthCookieHelper.setRefreshTokenCookie( + response.refreshToken(), response.refreshTokenExpiresIn()); + return ResponseEntity.status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + ReissueTokenResponse.of(response.accessToken()))); } @Operation( summary = "로그아웃", description = "엑세스 토큰과 리프레시 토큰을 이용하여, 엑세스 토큰을 블랙리스트에 추가하고, 저장된 리프레시 토큰을 제거합니다.") @PostMapping("/logout") - public ResponseEntity logout(@Valid @RequestBody LogoutRequest request) { - authFacade.logout(request); + public ResponseEntity logout( + @CookieValue(name = AUTH_REFRESH_TOKEN, required = false) String refreshToken, + @Valid @RequestBody LogoutRequest request) { + authFacade.logout(request, refreshToken); - return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), null)); + // 기존 리프레시 쿠키 삭제 + ResponseCookie clearCookie = AuthCookieHelper.clearCookie(AUTH_REFRESH_TOKEN); + return ResponseEntity.status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, clearCookie.toString()) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); } } diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.java index dbc8362..1a2be14 100644 --- a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.java +++ b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.java @@ -5,7 +5,6 @@ import jakarta.validation.constraints.Pattern; public record KakaoSignupRequest( - @Schema(description = "카카오 인가 코드") @NotBlank(message = "카카오 인가 코드를 입력해 주세요.") String code, @Schema(description = "멤버 카테고리") @NotBlank(message = "멤버 카테고리를 입력해 주세요.") @Pattern( diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.java index b1034c5..13f2f1c 100644 --- a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.java +++ b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.java @@ -4,6 +4,5 @@ import jakarta.validation.constraints.NotBlank; public record LogoutRequest( - @Schema(description = "엑세스 토큰") @NotBlank(message = "엑세스 토큰을 입력해 주세요.") String accessToken, - @Schema(description = "리프레시 토큰") @NotBlank(message = "리프레시 토큰을 입력해 주세요.") - String refreshToken) {} + @Schema(description = "엑세스 토큰") @NotBlank(message = "엑세스 토큰을 입력해 주세요.") + String accessToken) {} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/response/LoginResponse.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/response/LoginResponse.java new file mode 100644 index 0000000..bb8db2f --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/presentation/dto/response/LoginResponse.java @@ -0,0 +1,17 @@ +package com.ject.studytrip.auth.presentation.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record LoginResponse( + @Schema(description = "회원가입 필요 여부") boolean signupRequired, + @Schema(description = "엑세스 토큰(가입된 회원인 경우)") String accessToken) { + public static LoginResponse success(String accessToken) { + return new LoginResponse(false, accessToken); + } + + public static LoginResponse requiredSignup() { + return new LoginResponse(true, null); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/response/ReissueTokenResponse.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/response/ReissueTokenResponse.java new file mode 100644 index 0000000..0e4bc73 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/presentation/dto/response/ReissueTokenResponse.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ReissueTokenResponse(@Schema(description = "새로 발급된 엑세스 토큰") String accessToken) { + public static ReissueTokenResponse of(String accessToken) { + return new ReissueTokenResponse(accessToken); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/response/TokenResponse.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/response/TokenResponse.java deleted file mode 100644 index 724c791..0000000 --- a/src/main/java/com/ject/studytrip/auth/presentation/dto/response/TokenResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.ject.studytrip.auth.presentation.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record TokenResponse( - @Schema(description = "엑세스 토큰") String accessToken, - @Schema(description = "리프레시 토큰") String refreshToken) { - public static TokenResponse of(String accessToken, String refreshToken) { - return new TokenResponse(accessToken, refreshToken); - } -} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/helper/AuthCookieHelper.java b/src/main/java/com/ject/studytrip/auth/presentation/helper/AuthCookieHelper.java new file mode 100644 index 0000000..a530616 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/presentation/helper/AuthCookieHelper.java @@ -0,0 +1,37 @@ +package com.ject.studytrip.auth.presentation.helper; + +import static com.ject.studytrip.global.common.constants.CookieConstants.*; + +import java.time.Duration; +import org.springframework.http.ResponseCookie; + +public final class AuthCookieHelper { + public static ResponseCookie setOAuthSignupProfileCookie(String value) { + return setResponseCookie( + OAUTH_SIGNUP_KEY, value, Duration.ofMillis(OAUTH_SIGNUP_COOKIE_TTL_MILLIS)); + } + + public static ResponseCookie setRefreshTokenCookie(String value, long maxAge) { + return setResponseCookie(AUTH_REFRESH_TOKEN, value, Duration.ofMillis(maxAge)); + } + + private static ResponseCookie setResponseCookie(String name, String value, Duration maxAge) { + return ResponseCookie.from(name, value) + .httpOnly(true) + .secure(true) + .sameSite("None") + .maxAge(maxAge) + .path("/") + .build(); + } + + public static ResponseCookie clearCookie(String name) { + return ResponseCookie.from(name, "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(0) + .build(); + } +} diff --git a/src/main/java/com/ject/studytrip/global/common/constants/CacheKeyConstants.java b/src/main/java/com/ject/studytrip/global/common/constants/CacheKeyConstants.java index 95b8d47..e1f8bd9 100644 --- a/src/main/java/com/ject/studytrip/global/common/constants/CacheKeyConstants.java +++ b/src/main/java/com/ject/studytrip/global/common/constants/CacheKeyConstants.java @@ -1,13 +1,10 @@ package com.ject.studytrip.global.common.constants; -import lombok.Getter; -import lombok.RequiredArgsConstructor; +public final class CacheKeyConstants { -@Getter -@RequiredArgsConstructor -public enum CacheKeyConstants { - AUTH_REISSUE_TOKEN_PREFIX("auth::reissue::token:"), - AUTH_LOGOUT_TOKEN_PREFIX("auth::logout::token:"); + private CacheKeyConstants() {} - private final String value; + public static final String AUTH_REISSUE_TOKEN_PREFIX = "auth::reissue::token:"; + public static final String AUTH_LOGOUT_TOKEN_PREFIX = "auth::logout::token:"; + public static final String OAUTH_SIGNUP_PROFILE_PREFIX = "%s::signup::profile:"; } diff --git a/src/main/java/com/ject/studytrip/global/common/constants/CookieConstants.java b/src/main/java/com/ject/studytrip/global/common/constants/CookieConstants.java new file mode 100644 index 0000000..cd35a1f --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/common/constants/CookieConstants.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.global.common.constants; + +public final class CookieConstants { + + private CookieConstants() {} + + // 인증 관련 + public static final String AUTH_REFRESH_TOKEN = "auth_refresh"; + + // OAuth 관련 + public static final String OAUTH_SIGNUP_KEY = "oauth_signup_key"; + public static final long OAUTH_SIGNUP_COOKIE_TTL_MILLIS = 900000; +} diff --git a/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java b/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java index 7759771..caea19d 100644 --- a/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java +++ b/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java @@ -1,37 +1,34 @@ package com.ject.studytrip.global.common.constants; -import lombok.Getter; +public final class UrlConstants { + + private UrlConstants() {} -@Getter -public enum UrlConstants { // CORS 허용 도메인 - CORS_DOMAINS( - "http://localhost:8080", - "https://dev-api-studytrip.duckdns.org", - "http://localhost:5173", - "https://localhost:5173", - "https://ject-4-client.vercel.app"), + public static final String[] CORS_DOMAINS = { + "http://localhost:8080", + "https://dev-api-studytrip.duckdns.org", + "http://localhost:5173", + "https://localhost:5173", + "https://ject-4-client.vercel.app" + }; // 정적 리소스 경로 - STATIC_RESOURCES( - "/favicon.ico", - "/firebase-messaging-sw.js", - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html"), + public static final String[] STATIC_RESOURCES = { + "/favicon.ico", + "/firebase-messaging-sw.js", + "/.well-known/**", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + }; // OAuth 콜백 경로 - CALLBACK_PATHS("/auth/callback/**"), + public static final String[] CALLBACK_PATHS = {"/auth/callback/**"}; // Origin 추출이 필요한 경로 - ORIGIN_EXTRACT_PATHS("/api/auth/login/kakao", "/api/auth/signup/kakao"), + public static final String[] ORIGIN_EXTRACT_PATHS = {"/api/auth/login/kakao"}; // 인증이 필요없는 API 경로 - PERMIT_ALL_API_PATHS("/api/auth/**", "/api/trips/categories"); - - private final String[] urls; - - UrlConstants(String... urls) { - this.urls = urls; - } + public static final String[] PERMIT_ALL_API_PATHS = {"/api/auth/**", "/api/trips/categories"}; } diff --git a/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java b/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java index 67e4635..65ca7b0 100644 --- a/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java +++ b/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java @@ -53,7 +53,7 @@ private void defaultFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception { defaultFilterChain(http); - http.securityMatcher(STATIC_RESOURCES.getUrls()); + http.securityMatcher(STATIC_RESOURCES); http.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()); return http.build(); @@ -65,7 +65,7 @@ public SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception public SecurityFilterChain callbackFilterChain(HttpSecurity http) throws Exception { defaultFilterChain(http); - http.securityMatcher(CALLBACK_PATHS.getUrls()); + http.securityMatcher(CALLBACK_PATHS); http.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()); return http.build(); @@ -78,7 +78,7 @@ public SecurityFilterChain originExtractionFilterChain( HttpSecurity http, SecurityResponseHandler securityResponseHandler) throws Exception { defaultFilterChain(http); - http.securityMatcher(ORIGIN_EXTRACT_PATHS.getUrls()); + http.securityMatcher(ORIGIN_EXTRACT_PATHS); // Origin 추출 필터 등록 : CORS 이후 OriginExtractionFilter 실행 http.addFilterAfter(new OriginExtractionFilter(securityResponseHandler), CorsFilter.class); @@ -104,7 +104,7 @@ public SecurityFilterChain mainFilterChain(HttpSecurity http, TokenService token http.authorizeHttpRequests( authorize -> authorize - .requestMatchers(PERMIT_ALL_API_PATHS.getUrls()) + .requestMatchers(PERMIT_ALL_API_PATHS) .permitAll() .anyRequest() .authenticated()); // 그 외 요청은 모두 인증 수행 @@ -133,7 +133,7 @@ public UrlBasedCorsConfigurationSource corsConfigurationSource() { // - prod: PROD_DOMAIN 만 허용 // - Spring Active Profile 기반 분기 필요 // - 서비스 도메인, 서버 운영 환경 설정 완료 시 작업 - List allowedOrigins = Arrays.asList(CORS_DOMAINS.getUrls()); + List allowedOrigins = Arrays.asList(CORS_DOMAINS); config.setAllowedOrigins(allowedOrigins); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); diff --git a/src/main/java/com/ject/studytrip/global/security/OriginExtractionFilter.java b/src/main/java/com/ject/studytrip/global/security/OriginExtractionFilter.java index 0348f51..76655d4 100644 --- a/src/main/java/com/ject/studytrip/global/security/OriginExtractionFilter.java +++ b/src/main/java/com/ject/studytrip/global/security/OriginExtractionFilter.java @@ -39,7 +39,7 @@ protected void doFilterInternal( } // 허용된 origin 검증 - List allowedOrigins = Arrays.asList(CORS_DOMAINS.getUrls()); + List allowedOrigins = Arrays.asList(CORS_DOMAINS); if (!allowedOrigins.contains(origin)) { securityResponseHandler.sendResponse(response, CommonErrorCode.UNSUPPORTED_ORIGIN); return; diff --git a/src/main/java/com/ject/studytrip/member/application/service/MemberService.java b/src/main/java/com/ject/studytrip/member/application/service/MemberService.java index 1257582..da50d80 100644 --- a/src/main/java/com/ject/studytrip/member/application/service/MemberService.java +++ b/src/main/java/com/ject/studytrip/member/application/service/MemberService.java @@ -14,6 +14,7 @@ import com.ject.studytrip.member.domain.repository.MemberQueryRepository; import com.ject.studytrip.member.domain.repository.MemberRepository; import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -60,16 +61,15 @@ public Member getMember(Long memberId) { } @Transactional(readOnly = true) - public Member getMemberBySocialProviderAndSocialId( + public Optional getMemberBySocialProviderAndSocialId( SocialProvider socialProvider, String socialId) { - Member member = - memberRepository - .findBySocialProviderAndSocialId(socialProvider, socialId) - .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NEED_SIGNUP)); - - MemberPolicy.validateNotDeleted(member); - - return member; + return memberRepository + .findBySocialProviderAndSocialId(socialProvider, socialId) + .map( + member -> { + MemberPolicy.validateNotDeleted(member); + return member; + }); } @Transactional(readOnly = true) diff --git a/src/test/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileServiceTest.java b/src/test/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileServiceTest.java new file mode 100644 index 0000000..156b580 --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileServiceTest.java @@ -0,0 +1,122 @@ +package com.ject.studytrip.auth.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.domain.model.KakaoSignupProfile; +import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository; +import com.ject.studytrip.global.exception.CustomException; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +@DisplayName("KakaoSignupProfileService 단위 테스트") +class KakaoSignupProfileServiceTest extends BaseUnitTest { + private static final String VALID_SIGNUP_KEY = "kakao::signup::profile:valid-key"; + private static final String INVALID_SIGNUP_KEY = "kakao::signup::profile:invalid-key"; + private static final String KAKAO_ID = "12345"; + private static final String KAKAO_PROVIDER = "kakao"; + private static final String EMAIL = "test@kakao.com"; + private static final String PROFILE_IMAGE = "https://kakao.com/profile.jpg"; + + @InjectMocks private KakaoSignupProfileService kakaoSignupProfileService; + + @Mock private KakaoSignupProfileRedisRepository kakaoSignupProfileRedisRepository; + + @Nested + @DisplayName("saveAndIssueSignupKey 메서드는") + class SaveAndIssueSignupKey { + + @Test + @DisplayName("프로필 저장 후 발급된 키를 반환한다.") + void shouldReturnIssuedKey() { + // given + String issuedKey = "issued-signup-key"; + given( + kakaoSignupProfileRedisRepository.saveAndIssueSignupKey( + KAKAO_ID, EMAIL, PROFILE_IMAGE)) + .willReturn(issuedKey); + + // when + String result = + kakaoSignupProfileService.saveAndIssueSignupKey(KAKAO_ID, EMAIL, PROFILE_IMAGE); + + // then + assertThat(result).isEqualTo(issuedKey); + verify(kakaoSignupProfileRedisRepository) + .saveAndIssueSignupKey(KAKAO_ID, EMAIL, PROFILE_IMAGE); + } + } + + @Nested + @DisplayName("getSignupProfileByKey 메서드는") + class GetSignupProfileByKey { + + @Test + @DisplayName("유효한 signupKey로 조회하면 KakaoSignupProfile을 반환한다.") + void shouldReturnKakaoSignupProfileWhenKeyIsValid() { + // given + KakaoSignupProfile expectedProfile = + KakaoSignupProfile.of(KAKAO_ID, KAKAO_PROVIDER, EMAIL, PROFILE_IMAGE); + given(kakaoSignupProfileRedisRepository.findBySignupKey(VALID_SIGNUP_KEY)) + .willReturn(Optional.of(expectedProfile)); + + // when + KakaoSignupProfile result = + kakaoSignupProfileService.getSignupProfileByKey(VALID_SIGNUP_KEY); + + // then + assertThat(result).isEqualTo(expectedProfile); + } + + @Test + @DisplayName("signupKey가 null이면 MISSING_KAKAO_SIGNUP_KEY 예외가 발생한다.") + void shouldThrowExceptionWhenSignupKeyIsNull() { + // when & then + assertThatThrownBy(() -> kakaoSignupProfileService.getSignupProfileByKey(null)) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.getMessage()); + } + + @Test + @DisplayName("signupKey가 빈 문자열이면 MISSING_KAKAO_SIGNUP_KEY 예외가 발생한다.") + void shouldThrowExceptionWhenSignupKeyIsBlank() { + // when & then + assertThatThrownBy(() -> kakaoSignupProfileService.getSignupProfileByKey("")) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.getMessage()); + } + + @Test + @DisplayName("signupKey가 공백만 있으면 MISSING_KAKAO_SIGNUP_KEY 예외가 발생한다.") + void shouldThrowExceptionWhenSignupKeyIsWhitespace() { + // when & then + assertThatThrownBy(() -> kakaoSignupProfileService.getSignupProfileByKey(" ")) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.getMessage()); + } + + @Test + @DisplayName("존재하지 않는 signupKey로 조회하면 INVALID_KAKAO_SIGNUP_KEY 예외가 발생한다.") + void shouldThrowExceptionWhenSignupKeyDoesNotExist() { + // given + given(kakaoSignupProfileRedisRepository.findBySignupKey(INVALID_SIGNUP_KEY)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy( + () -> + kakaoSignupProfileService.getSignupProfileByKey( + INVALID_SIGNUP_KEY)) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY.getMessage()); + } + } +} diff --git a/src/test/java/com/ject/studytrip/auth/application/service/TokenServiceTest.java b/src/test/java/com/ject/studytrip/auth/application/service/TokenServiceTest.java index 2e09f97..3997d2b 100644 --- a/src/test/java/com/ject/studytrip/auth/application/service/TokenServiceTest.java +++ b/src/test/java/com/ject/studytrip/auth/application/service/TokenServiceTest.java @@ -7,11 +7,11 @@ import static org.mockito.Mockito.*; import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.auth.application.dto.TokenInfo; import com.ject.studytrip.auth.domain.error.AuthErrorCode; import com.ject.studytrip.auth.domain.repository.LogoutTokenRedisRepository; import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository; import com.ject.studytrip.auth.infra.provider.TokenProvider; -import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; import com.ject.studytrip.global.exception.CustomException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -53,7 +53,7 @@ void shouldReturnTokenResponseWhenMemberIdAndRoleProvided() { .thenReturn(REFRESH_TOKEN_EXPIRATION_TIME); // when - TokenResponse response = tokenService.getTokens(MEMBER_ID, ROLE); + TokenInfo response = tokenService.getTokens(MEMBER_ID, ROLE); // then assertThat(response.accessToken()).isEqualTo(ACCESS_TOKEN); @@ -77,7 +77,7 @@ void shouldReissueTokenWhenRefreshTokenIsValid() { given(tokenProvider.createRefreshToken()).willReturn(NEW_REFRESH_TOKEN); // when - TokenResponse response = tokenService.reissueToken(REFRESH_TOKEN, MEMBER_ID, ROLE); + TokenInfo response = tokenService.reissueToken(REFRESH_TOKEN, MEMBER_ID, ROLE); // then assertThat(response.accessToken()).isEqualTo(NEW_ACCESS_TOKEN); diff --git a/src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java index 1c96359..4d4e77b 100644 --- a/src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java +++ b/src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java @@ -3,15 +3,9 @@ import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; public class KakaoSignupRequestFixture { - private String code = "valid-code"; private String category = "STUDENT"; private String nickname = "민우"; - public KakaoSignupRequestFixture withCode(String code) { - this.code = code; - return this; - } - public KakaoSignupRequestFixture withCategory(String category) { this.category = category; return this; @@ -23,6 +17,6 @@ public KakaoSignupRequestFixture withNickname(String nickname) { } public KakaoSignupRequest build() { - return new KakaoSignupRequest(code, category, nickname); + return new KakaoSignupRequest(category, nickname); } } diff --git a/src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java index 0383e6b..8d81402 100644 --- a/src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java +++ b/src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java @@ -4,19 +4,13 @@ public class LogoutRequestFixture { private String accessToken = null; - private String refreshToken = null; public LogoutRequestFixture withAccessToken(String accessToken) { this.accessToken = accessToken; return this; } - public LogoutRequestFixture withRefreshToken(String refreshToken) { - this.refreshToken = refreshToken; - return this; - } - public LogoutRequest build() { - return new LogoutRequest(accessToken, refreshToken); + return new LogoutRequest(accessToken); } } diff --git a/src/test/java/com/ject/studytrip/auth/fixture/TokenReissueRequestFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/TokenReissueRequestFixture.java deleted file mode 100644 index 62fcdc8..0000000 --- a/src/test/java/com/ject/studytrip/auth/fixture/TokenReissueRequestFixture.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.ject.studytrip.auth.fixture; - -import com.ject.studytrip.auth.presentation.dto.request.TokenReissueRequest; - -public class TokenReissueRequestFixture { - private String refreshToken = null; - - public TokenReissueRequestFixture withRefreshToken(String refreshToken) { - this.refreshToken = refreshToken; - return this; - } - - public TokenReissueRequest build() { - return new TokenReissueRequest(refreshToken); - } -} diff --git a/src/test/java/com/ject/studytrip/auth/helper/AuthCookieTestHelper.java b/src/test/java/com/ject/studytrip/auth/helper/AuthCookieTestHelper.java new file mode 100644 index 0000000..9d369ad --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/helper/AuthCookieTestHelper.java @@ -0,0 +1,24 @@ +package com.ject.studytrip.auth.helper; + +import jakarta.servlet.http.Cookie; +import org.springframework.stereotype.Component; + +@Component +public class AuthCookieTestHelper { + + public Cookie createKakaoSignupProfileCookie(String name, String signupKey) { + Cookie cookie = new Cookie(name, signupKey); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/"); + return cookie; + } + + public Cookie createRefreshTokenCookie(String name, String refreshToken) { + Cookie cookie = new Cookie(name, refreshToken); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/"); + return cookie; + } +} diff --git a/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java index 84dcdbe..363f131 100644 --- a/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java @@ -1,13 +1,16 @@ package com.ject.studytrip.auth.presentation.controller; +import static com.ject.studytrip.global.common.constants.CookieConstants.*; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import com.ject.studytrip.BaseIntegrationTest; import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository; import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository; import com.ject.studytrip.auth.fixture.*; -import com.ject.studytrip.auth.fixture.TokenReissueRequestFixture; +import com.ject.studytrip.auth.helper.AuthCookieTestHelper; import com.ject.studytrip.auth.helper.KakaoOauthTestHelper; import com.ject.studytrip.auth.helper.TokenTestHelper; import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; @@ -16,17 +19,18 @@ import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; import com.ject.studytrip.auth.presentation.dto.request.LogoutRequest; -import com.ject.studytrip.auth.presentation.dto.request.TokenReissueRequest; import com.ject.studytrip.global.exception.error.CommonErrorCode; import com.ject.studytrip.member.domain.error.MemberErrorCode; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.helper.MemberTestHelper; +import jakarta.servlet.http.Cookie; import java.time.Duration; 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.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -36,17 +40,22 @@ class AuthControllerIntegrationTest extends BaseIntegrationTest { private static final String BASE_AUTH_URL = "/api/auth"; private static final String TEST_ORIGIN = "http://localhost:8080"; + private static final String RESPONSE_COOKIE_NAME = "Set-Cookie"; @Autowired private MemberTestHelper memberTestHelper; @Autowired private TokenTestHelper tokenTestHelper; @Autowired private KakaoOauthTestHelper kakaoOauthTestHelper; + @Autowired private AuthCookieTestHelper authCookieTestHelper; + @Autowired private RefreshTokenRedisRepository refreshTokenRedisRepository; + @Autowired private KakaoSignupProfileRedisRepository kakaoSignupProfileRedisRepository; @MockitoBean KakaoOauthProvider kakaoOauthProvider; private Member member; private String accessToken; private String refreshToken; + private String signupKey; @BeforeEach void setUp() { @@ -56,7 +65,11 @@ void setUp() { member.getId().toString(), member.getRole().name()); refreshToken = tokenTestHelper.createRefreshToken(); - long refreshTokenExpirationTime = Duration.ofSeconds(30).getSeconds(); + signupKey = + kakaoSignupProfileRedisRepository.saveAndIssueSignupKey( + member.getSocialId(), member.getEmail(), member.getProfileImage()); + + long refreshTokenExpirationTime = Duration.ofSeconds(30).toMillis(); refreshTokenRedisRepository.saveRefreshToken( member.getId().toString(), refreshToken, refreshTokenExpirationTime); } @@ -80,59 +93,64 @@ private ResultActions getResultActions(KakaoLoginRequest request) throws Excepti } @Test - @DisplayName("가입되지 않은 사용자 인가 코드로 로그인 시 409 Conflict를 반환한다.") - void shouldReturnConflictWhenMemberNotSignUp() throws Exception { + @DisplayName("탈퇴한 사용자가 인가 코드로 로그인 시 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMemberAlreadyDeleted() throws Exception { // given member.updateDeletedAt(); KakaoLoginRequest request = kakaoLoginRequestFixture.build(); KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); kakaoOauthTestHelper.mockThrowException( - kakaoTokenResponse, MemberErrorCode.MEMBER_NEED_SIGNUP); + kakaoTokenResponse, MemberErrorCode.MEMBER_ALREADY_DELETED); // when ResultActions resultActions = getResultActions(request); // then resultActions - .andExpect(status().isConflict()) + .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.success").value(false)) .andExpect( jsonPath("$.status") - .value(MemberErrorCode.MEMBER_NEED_SIGNUP.getStatus().value())) - .andExpect( - jsonPath("$.data.error") - .value(MemberErrorCode.MEMBER_NEED_SIGNUP.name())) + .value( + MemberErrorCode.MEMBER_ALREADY_DELETED + .getStatus() + .value())) .andExpect( jsonPath("$.data.message") - .value(MemberErrorCode.MEMBER_NEED_SIGNUP.getMessage())); + .value(MemberErrorCode.MEMBER_ALREADY_DELETED.getMessage())); } @Test - @DisplayName("탈퇴한 사용자가 인가 코드로 로그인 시 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenMemberAlreadyDeleted() throws Exception { + @DisplayName("가입되지 않은 사용자 인가 코드로 로그인 시 회원가입 필요 응답을 반환한다.") + void shouldReturnSignupRequiredWhenMemberNotSignUp() throws Exception { // given - member.updateDeletedAt(); + memberTestHelper.deleteMemberById(member.getId()); KakaoLoginRequest request = kakaoLoginRequestFixture.build(); KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); - kakaoOauthTestHelper.mockThrowException( - kakaoTokenResponse, MemberErrorCode.MEMBER_ALREADY_DELETED); + KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); + kakaoOauthTestHelper.mockSuccess(kakaoTokenResponse, kakaoUserInfoResponse); // when ResultActions resultActions = getResultActions(request); // then resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.signupRequired").value(true)) + .andExpect(jsonPath("$.data.accessToken").doesNotExist()) + .andExpect(header().exists(RESPONSE_COOKIE_NAME)) .andExpect( - jsonPath("$.status") - .value( - MemberErrorCode.MEMBER_ALREADY_DELETED - .getStatus() - .value())) + header().string( + HttpHeaders.SET_COOKIE, + containsString(OAUTH_SIGNUP_KEY + "="))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) .andExpect( - jsonPath("$.data.message") - .value(MemberErrorCode.MEMBER_ALREADY_DELETED.getMessage())); + header().string( + HttpHeaders.SET_COOKIE, + containsString("SameSite=None"))); } @Test @@ -152,27 +170,91 @@ void shouldReturnTokenResponseWhenLoginIsSuccessful() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.signupRequired").value(false)) .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) - .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()); + .andExpect(header().exists(RESPONSE_COOKIE_NAME)) + .andExpect( + header().string( + HttpHeaders.SET_COOKIE, + containsString(AUTH_REFRESH_TOKEN + "="))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) + .andExpect( + header().string( + HttpHeaders.SET_COOKIE, + containsString("SameSite=None"))); + ; } } @Nested @DisplayName("카카오 회원가입 API") class KakaoSignup { - private final KakaoTokenResponseFixture kakaoTokenResponseFixture = - new KakaoTokenResponseFixture(); private final KakaoSignupRequestFixture kakaoSignupRequestFixture = new KakaoSignupRequestFixture(); - private final KakaoUserInfoResponseFixture kakaoUserInfoResponseFixture = - new KakaoUserInfoResponseFixture(); - private ResultActions getResultActions(KakaoSignupRequest request) throws Exception { + private ResultActions getResultActions(KakaoSignupRequest request, Cookie cookie) + throws Exception { return mockMvc.perform( post(BASE_AUTH_URL + "/signup/kakao") - .header("Origin", TEST_ORIGIN) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); + .content(objectMapper.writeValueAsString(request)) + .cookie(cookie)); + } + + @Test + @DisplayName("회원가입 요청 시 signupKey 쿠키가 없으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenSignupKeyCookieMissing() throws Exception { + // given + memberTestHelper.deleteMemberById(member.getId()); + KakaoSignupRequest request = kakaoSignupRequestFixture.build(); + Cookie nullCookie = + authCookieTestHelper.createKakaoSignupProfileCookie("NULL_COOKIE", signupKey); + + // when + ResultActions resultActions = getResultActions(request, nullCookie); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.getMessage())); + } + + @Test + @DisplayName("회원가입 요청 시 signupKey가 유효하지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenSignupKeyInvalid() throws Exception { + // given + memberTestHelper.deleteMemberById(member.getId()); + KakaoSignupRequest request = kakaoSignupRequestFixture.build(); + Cookie invalidCookie = + authCookieTestHelper.createKakaoSignupProfileCookie( + OAUTH_SIGNUP_KEY, "invalid.pending.key"); + + // when - 유효하지 않은 쿠키로 요청 + ResultActions resultActions = getResultActions(request, invalidCookie); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY.getMessage())); } @Test @@ -180,12 +262,12 @@ private ResultActions getResultActions(KakaoSignupRequest request) throws Except void shouldThrowExceptionWhenSignupForExistingMember() throws Exception { // given KakaoSignupRequest request = kakaoSignupRequestFixture.build(); - KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); - KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); - kakaoOauthTestHelper.mockSuccess(kakaoTokenResponse, kakaoUserInfoResponse); + Cookie cookie = + authCookieTestHelper.createKakaoSignupProfileCookie( + OAUTH_SIGNUP_KEY, signupKey); // when - ResultActions resultActions = getResultActions(request); + ResultActions resultActions = getResultActions(request, cookie); // then resultActions @@ -208,44 +290,53 @@ void shouldReturnTokenResponseWhenSignupIsSuccessful() throws Exception { // given memberTestHelper.deleteMemberById(member.getId()); KakaoSignupRequest request = kakaoSignupRequestFixture.build(); - KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); - KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); - kakaoOauthTestHelper.mockSuccess(kakaoTokenResponse, kakaoUserInfoResponse); + Cookie cookie = + authCookieTestHelper.createKakaoSignupProfileCookie( + OAUTH_SIGNUP_KEY, signupKey); // when - ResultActions resultActions = getResultActions(request); + ResultActions resultActions = getResultActions(request, cookie); // then resultActions .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.signupRequired").value(false)) .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) - .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()); + .andExpect(header().exists(RESPONSE_COOKIE_NAME)) + .andExpect( + header().string( + HttpHeaders.SET_COOKIE, + containsString(AUTH_REFRESH_TOKEN + "="))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) + .andExpect( + header().string( + HttpHeaders.SET_COOKIE, + containsString("SameSite=None"))); + ; } } @Nested @DisplayName("토큰 재발급 API") class ReissueToken { - private final TokenReissueRequestFixture tokenReissueRequestFixture = - new TokenReissueRequestFixture(); - private ResultActions getResultActions(TokenReissueRequest request) throws Exception { - return mockMvc.perform( - post(BASE_AUTH_URL + "/token/reissue") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); + private ResultActions getResultActions(Cookie cookie) throws Exception { + return mockMvc.perform(post(BASE_AUTH_URL + "/token/reissue").cookie(cookie)); } @Test - @DisplayName("리프레시 토큰이 null 이면 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenRefreshTokenIsNull() throws Exception { + @DisplayName("리프레시 토큰이 null 혹은 비어있을 경우 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenRefreshTokenIsNullOrBlank() throws Exception { // given - TokenReissueRequest request = tokenReissueRequestFixture.build(); + Cookie nullCookie = + authCookieTestHelper.createRefreshTokenCookie( + "null.refresh.cookie", refreshToken); // when - ResultActions resultActions = getResultActions(request); + ResultActions resultActions = getResultActions(nullCookie); // then resultActions @@ -253,24 +344,22 @@ void shouldReturnBadRequestWhenRefreshTokenIsNull() throws Exception { .andExpect(jsonPath("$.success").value(false)) .andExpect( jsonPath("$.status") - .value( - CommonErrorCode.METHOD_ARGUMENT_NOT_VALID - .getStatus() - .value())) + .value(AuthErrorCode.MISSING_REFRESH_TOKEN.getStatus().value())) .andExpect( jsonPath("$.data.message") - .value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getMessage())); + .value(AuthErrorCode.MISSING_REFRESH_TOKEN.getMessage())); } @Test @DisplayName("리프레시 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") void shouldReturnUnauthorizedWhenRefreshTokenIsInvalid() throws Exception { // given - TokenReissueRequest request = - tokenReissueRequestFixture.withRefreshToken("invalid.refresh.token").build(); + Cookie invalidRefreshCookie = + authCookieTestHelper.createRefreshTokenCookie( + AUTH_REFRESH_TOKEN, "invalid:refresh:cookie"); // when - ResultActions resultActions = getResultActions(request); + ResultActions resultActions = getResultActions(invalidRefreshCookie); // then resultActions @@ -288,11 +377,11 @@ void shouldReturnUnauthorizedWhenRefreshTokenIsInvalid() throws Exception { @DisplayName("유효한 요청이 들어오면 새로운 엑세스 토큰과 리프레시 토큰을 재발급한다.") void shouldReissueTokenWhenRequestIsValid() throws Exception { // given - TokenReissueRequest request = - tokenReissueRequestFixture.withRefreshToken(refreshToken).build(); + Cookie refreshCookie = + authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken); // when - ResultActions resultActions = getResultActions(request); + ResultActions resultActions = getResultActions(refreshCookie); // then resultActions @@ -300,7 +389,17 @@ void shouldReissueTokenWhenRequestIsValid() throws Exception { .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) - .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()); + .andExpect(header().exists(RESPONSE_COOKIE_NAME)) + .andExpect( + header().string( + HttpHeaders.SET_COOKIE, + containsString(AUTH_REFRESH_TOKEN + "="))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) + .andExpect( + header().string( + HttpHeaders.SET_COOKIE, + containsString("SameSite=None"))); } } @@ -309,22 +408,25 @@ void shouldReissueTokenWhenRequestIsValid() throws Exception { class Logout { private final LogoutRequestFixture logoutRequestFixture = new LogoutRequestFixture(); - private ResultActions getResultActions(LogoutRequest request) throws Exception { + private ResultActions getResultActions(LogoutRequest request, Cookie cookie) + throws Exception { return mockMvc.perform( post(BASE_AUTH_URL + "/logout") - .header("Origin", TEST_ORIGIN) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); + .content(objectMapper.writeValueAsString(request)) + .cookie(cookie)); } @Test @DisplayName("엑세스 토큰이 null 이면 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenAccessTokenIsNull() throws Exception { // given - LogoutRequest request = logoutRequestFixture.withRefreshToken(refreshToken).build(); + LogoutRequest request = logoutRequestFixture.build(); + Cookie refreshCookie = + authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken); // when - ResultActions resultActions = getResultActions(request); + ResultActions resultActions = getResultActions(request, refreshCookie); // then resultActions @@ -342,66 +444,64 @@ void shouldReturnBadRequestWhenAccessTokenIsNull() throws Exception { } @Test - @DisplayName("리프레시 토큰이 null 이면 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenRefreshTokenIsNull() throws Exception { + @DisplayName("엑세스 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsInvalid() throws Exception { // given - LogoutRequest request = logoutRequestFixture.withAccessToken(accessToken).build(); + LogoutRequest request = + logoutRequestFixture.withAccessToken("invalid.access.token").build(); + Cookie refreshCookie = + authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken); // when - ResultActions resultActions = getResultActions(request); + ResultActions resultActions = getResultActions(request, refreshCookie); // then resultActions - .andExpect(status().isBadRequest()) + .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.success").value(false)) .andExpect( jsonPath("$.status") - .value( - CommonErrorCode.METHOD_ARGUMENT_NOT_VALID - .getStatus() - .value())) + .value(AuthErrorCode.INVALID_JWT_TOKEN.getStatus().value())) .andExpect( jsonPath("$.data.message") - .value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getMessage())); + .value(AuthErrorCode.INVALID_JWT_TOKEN.getMessage())); } @Test - @DisplayName("엑세스 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") - void shouldReturnUnauthorizedWhenAccessTokenIsInvalid() throws Exception { + @DisplayName("리프레시 토큰이 null 혹은 비어있을 경우 BAD REQUEST를 반환한다.") + void shouldReturnBadRequestWhenRefreshTokenIsNullOrBlank() throws Exception { // given - LogoutRequest request = - logoutRequestFixture - .withAccessToken("invalid.access.token") - .withRefreshToken(refreshToken) - .build(); + LogoutRequest request = logoutRequestFixture.withAccessToken(accessToken).build(); + Cookie nullCookie = + authCookieTestHelper.createRefreshTokenCookie( + "null.refresh.cookie", refreshToken); // when - ResultActions resultActions = getResultActions(request); + ResultActions resultActions = getResultActions(request, nullCookie); // then resultActions - .andExpect(status().isUnauthorized()) + .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.success").value(false)) .andExpect( jsonPath("$.status") - .value(AuthErrorCode.INVALID_JWT_TOKEN.getStatus().value())) + .value(AuthErrorCode.MISSING_REFRESH_TOKEN.getStatus().value())) .andExpect( jsonPath("$.data.message") - .value(AuthErrorCode.INVALID_JWT_TOKEN.getMessage())); + .value(AuthErrorCode.MISSING_REFRESH_TOKEN.getMessage())); } @Test @DisplayName("리프레시 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") void shouldReturnUnauthorizedWhenRefreshTokenIsInvalid() throws Exception { // given - LogoutRequest request = - logoutRequestFixture - .withAccessToken(accessToken) - .withRefreshToken("invalid.refresh.token") - .build(); + LogoutRequest request = logoutRequestFixture.withAccessToken(accessToken).build(); + Cookie invalidRefreshTokenCookie = + authCookieTestHelper.createRefreshTokenCookie( + AUTH_REFRESH_TOKEN, "invalid.refresh.token"); // when - ResultActions resultActions = getResultActions(request); + ResultActions resultActions = getResultActions(request, invalidRefreshTokenCookie); // then resultActions @@ -419,14 +519,12 @@ void shouldReturnUnauthorizedWhenRefreshTokenIsInvalid() throws Exception { @DisplayName("유효한 요청이 들어오면, 엑세스 토큰을 블랙리스트에 추가하고, 저장된 리프레시 토큰을 제거합니다.") void shouldLogoutWhenRequestIsValid() throws Exception { // given - LogoutRequest request = - logoutRequestFixture - .withAccessToken(accessToken) - .withRefreshToken(refreshToken) - .build(); + LogoutRequest request = logoutRequestFixture.withAccessToken(accessToken).build(); + Cookie refreshCookie = + authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken); // when - ResultActions resultActions = getResultActions(request); + ResultActions resultActions = getResultActions(request, refreshCookie); // then resultActions diff --git a/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java b/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java index 87d8851..a3f8544 100644 --- a/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java +++ b/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java @@ -249,22 +249,6 @@ void shouldReturnMemberWhenMemberIdExists() { @DisplayName("getMemberBySocialProviderAndSocialId 메서드는") class GetMemberBySocialProviderAndSocialId { - @Test - @DisplayName("소셜 ID로 조회 시 존재하지 않으면 예외가 발생한다.") - void shouldThrowExceptionWhenSocialIdNotFound() { - // given - given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy( - () -> - memberService.getMemberBySocialProviderAndSocialId( - SocialProvider.KAKAO, socialId)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_NEED_SIGNUP.getMessage()); - } - @Test @DisplayName("탈퇴한 Member라면 예외가 발생한다.") void shouldThrowExceptionWhenMemberAlreadyDeleted() { @@ -291,8 +275,9 @@ void shouldReturnMemberWhenSocialIdExists() { // when Member result = - memberService.getMemberBySocialProviderAndSocialId( - SocialProvider.KAKAO, socialId); + memberService + .getMemberBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId) + .get(); // then assertThat(result).isEqualTo(member);