diff --git a/api-module/src/main/java/hongik/triple/apimodule/ApiModuleApplication.java b/api-module/src/main/java/hongik/triple/apimodule/ApiModuleApplication.java index 9bafdb1..65388a7 100644 --- a/api-module/src/main/java/hongik/triple/apimodule/ApiModuleApplication.java +++ b/api-module/src/main/java/hongik/triple/apimodule/ApiModuleApplication.java @@ -6,8 +6,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @Slf4j @SpringBootApplication(scanBasePackageClasses = { @@ -16,8 +14,6 @@ AcneLogInfraRoot.class, ApiModuleApplication.class }) -@EntityScan(basePackages = "hongik.triple.domainmodule") // 도메인 모듈의 엔티티 경로 -@EnableJpaRepositories(basePackages = "hongik.triple.domainmodule") // 도메인 모듈의 레포지토리 경로 public class ApiModuleApplication { public static void main(String[] args) { diff --git a/api-module/src/main/java/hongik/triple/apimodule/application/member/MemberService.java b/api-module/src/main/java/hongik/triple/apimodule/application/member/MemberService.java index 16b2052..8c8c42a 100644 --- a/api-module/src/main/java/hongik/triple/apimodule/application/member/MemberService.java +++ b/api-module/src/main/java/hongik/triple/apimodule/application/member/MemberService.java @@ -1,7 +1,9 @@ package hongik.triple.apimodule.application.member; +import hongik.triple.apimodule.global.security.jwt.TokenProvider; import hongik.triple.commonmodule.dto.member.MemberReq; import hongik.triple.commonmodule.dto.member.MemberRes; +import hongik.triple.commonmodule.enumerate.MemberType; import hongik.triple.domainmodule.domain.member.Member; import hongik.triple.domainmodule.domain.member.repository.MemberRepository; import hongik.triple.inframodule.oauth.google.GoogleClient; @@ -22,26 +24,32 @@ public class MemberService { private final MemberRepository memberRepository; private final KakaoClient kakaoClient; private final GoogleClient googleClient; + private final TokenProvider tokenProvider; - @Transactional - public void withdrawal(Member member) { - memberRepository.delete(member); + public String getKakaoLoginUrl(String redirectUri) { + return kakaoClient.getKakaoAuthUrl(redirectUri); } - public MemberRes loginWithKakao(String code, String redirectUri) { - KakaoToken kakaoToken = kakaoClient.getKakaoAccessToken(code, redirectUri); - KakaoProfile kakaoProfile = kakaoClient.getMemberInfo(kakaoToken); + public String getGoogleLoginUrl(String redirectUri) { + return googleClient.getGoogleAuthUrl(redirectUri); + } - return register(kakaoProfile.kakao_account().email(), kakaoProfile.properties().nickname()); + public KakaoProfile loginWithKakao(String authorizationCode, String redirectUri) { + KakaoToken kakaoToken = kakaoClient.getKakaoAccessToken(authorizationCode, redirectUri); + return kakaoClient.getMemberInfo(kakaoToken); } - public MemberRes loginWithGoogle(String accessToken, String redirectUri) { - GoogleToken googleToken = googleClient.getGoogleAccessToken(accessToken, redirectUri); - GoogleProfile googleProfile = googleClient.getMemberInfo(googleToken); + public GoogleProfile loginWithGoogle(String authorizationCode, String redirectUri) { + GoogleToken googleToken = googleClient.getGoogleAccessToken(authorizationCode, redirectUri); + return googleClient.getMemberInfo(googleToken); + } - return register(googleProfile.email(), googleProfile.name()); + @Transactional + public void withdrawal(Member member) { + memberRepository.delete(member); } + @Transactional public void logout() { } @@ -56,7 +64,7 @@ public MemberRes getProfile(Member member) { @Transactional public MemberRes updateProfile(Member member, MemberReq memberReq) { - member.update(memberReq.name(), memberReq.skin_type()); + member.updateSkinType(memberReq.skin_type()); Member updateMember = memberRepository.save(member); return MemberRes.builder() @@ -66,8 +74,9 @@ public MemberRes updateProfile(Member member, MemberReq memberReq) { .build(); } - @Transactional - protected MemberRes register(String email, String nickname) { + // TODO: DB 회원가입 실패 시, 카카오에서도 회원 가입 실패로 보상 트랜잭션 처리 필요 + @Transactional // 독립적인 트랜잭션으로 실행, 상위 트랜잭션은 읽기 트랜잭션으로 유지 + public MemberRes register(String email, String nickname, MemberType memberType) { if (email == null || email.isEmpty()) { throw new IllegalArgumentException("Email cannot be null or empty"); } @@ -75,21 +84,19 @@ protected MemberRes register(String email, String nickname) { throw new IllegalArgumentException("Nickname cannot be null or empty"); } - return memberRepository.findByEmail(email) - .map(member -> - MemberRes.builder() - .id(member.getMemberId()) - .email(member.getEmail()) - .name(member.getName()) - .build()) + Member member = memberRepository.findByEmail(email) .orElseGet(() -> { - Member newMember = new Member(email, nickname); - Member saveMember = memberRepository.save(newMember); - return MemberRes.builder() - .id(saveMember.getMemberId()) - .email(saveMember.getEmail()) - .name(saveMember.getName()) - .build(); + Member newMember = new Member(nickname, email, memberType); + return memberRepository.save(newMember); }); + + String accessToken = tokenProvider.createToken(member).accessToken(); + + return MemberRes.builder() + .id(member.getMemberId()) + .email(member.getEmail()) + .name(member.getName()) + .accessToken(accessToken) + .build(); } } \ No newline at end of file diff --git a/api-module/src/main/java/hongik/triple/apimodule/global/config/SecurityConfig.java b/api-module/src/main/java/hongik/triple/apimodule/global/config/SecurityConfig.java index b24cc14..fd25aa3 100644 --- a/api-module/src/main/java/hongik/triple/apimodule/global/config/SecurityConfig.java +++ b/api-module/src/main/java/hongik/triple/apimodule/global/config/SecurityConfig.java @@ -67,8 +67,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/error").permitAll() .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/member/**").authenticated() - .requestMatchers("/api/v1/contest/{contest_id}/team/**").authenticated() - .requestMatchers("/api/v2/apply/**").authenticated() // 이외의 모든 요청은 인증 정보 필요 .anyRequest().permitAll()); diff --git a/api-module/src/main/java/hongik/triple/apimodule/global/security/PrincipalDetails.java b/api-module/src/main/java/hongik/triple/apimodule/global/security/PrincipalDetails.java index 4d69fef..7180dab 100644 --- a/api-module/src/main/java/hongik/triple/apimodule/global/security/PrincipalDetails.java +++ b/api-module/src/main/java/hongik/triple/apimodule/global/security/PrincipalDetails.java @@ -30,8 +30,8 @@ public PrincipalDetails(Member member, Map attributes) { // 권한 정보 반환 (GENERAL, ADMIN 중 하나) @Override public Collection getAuthorities() { - Collection authorities = new ArrayList(); - authorities.add(new SimpleGrantedAuthority(member.getMemberType())); + Collection authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_" + member.getMemberType().name())); return authorities; } @@ -70,15 +70,4 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return true; } - -// OAuth2User 인터페이스 메서드 (필요시 구현) -// @Override -// public String getName() { -// return member.getName(); -// } -// -// @Override -// public Map getAttributes() { -// return attributes; -// } } diff --git a/api-module/src/main/java/hongik/triple/apimodule/global/security/jwt/JwtFilter.java b/api-module/src/main/java/hongik/triple/apimodule/global/security/jwt/JwtFilter.java index f474bf5..db0b66e 100644 --- a/api-module/src/main/java/hongik/triple/apimodule/global/security/jwt/JwtFilter.java +++ b/api-module/src/main/java/hongik/triple/apimodule/global/security/jwt/JwtFilter.java @@ -24,7 +24,6 @@ public class JwtFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); - // String requestURI = request.getRequestURI(); // 토큰이 존재할 경우, Authentication에 인증 정보 저장 및 로그 출력 if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { diff --git a/api-module/src/main/java/hongik/triple/apimodule/global/security/jwt/TokenProvider.java b/api-module/src/main/java/hongik/triple/apimodule/global/security/jwt/TokenProvider.java index 8f7be35..9eb33ad 100644 --- a/api-module/src/main/java/hongik/triple/apimodule/global/security/jwt/TokenProvider.java +++ b/api-module/src/main/java/hongik/triple/apimodule/global/security/jwt/TokenProvider.java @@ -1,5 +1,7 @@ package hongik.triple.apimodule.global.security.jwt; +import hongik.triple.apimodule.global.security.PrincipalDetails; +import hongik.triple.commonmodule.enumerate.MemberType; import hongik.triple.commonmodule.exception.ApplicationException; import hongik.triple.commonmodule.exception.ErrorCode; import hongik.triple.domainmodule.domain.member.Member; @@ -46,12 +48,14 @@ protected void init() { * @return 생성된 액세스 토큰 정보 반환 */ private String createAccessToken(Member member) { - Claims claims = getClaims(member); Date now = new Date(); + Date expirationDate = new Date(now.getTime() + accessTokenExpirationTime); + return Jwts.builder() - .claims(claims) + .subject(member.getEmail()) + .claim("memberType", member.getMemberType().name()) .issuedAt(now) - .expiration(new Date(now.getTime() + accessTokenExpirationTime)) + .expiration(expirationDate) .signWith(key) .compact(); } @@ -84,21 +88,6 @@ public boolean validateToken(String token) { } } - /** - * 리프레쉬 토큰 기반으로 액세스 토큰 재발급 + 리프레쉬 토큰의 유효기간이 액세스 토큰의 유효기간보다 짧을 경우, 리프레쉬 토큰도 재발급 - * @param member - 재발급을 요청한 사용자 정보 - * @param refreshToken - 재발급을 요청했던 리프레쉬 토큰 - * @return 재발급된 액세스 토큰을 담은 TokenDto 객체 반환 - */ - public TokenDto reissue(Member member, String refreshToken) { - // 액세스 토큰 재발급 - String accessToken = createAccessToken(member); - - return TokenDto.builder() - .accessToken(accessToken) - .build(); - } - /** * 토큰에서 정보를 추출해서 Authentication 객체를 반환 * @param token - 액세스 토큰으로, 해당 토큰에서 정보를 추출해서 사용 @@ -106,12 +95,14 @@ public TokenDto reissue(Member member, String refreshToken) { */ public Authentication getAuthentication(String token) { String email = getEmail(token); -// Member member = memberRepository.findMemberByEmailAndMemberTypeAndDeletedAtIsNull(email, memberType) -// .orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION)); -// PrincipalDetails principalDetails = new PrincipalDetails(member); -// -// return new UsernamePasswordAuthenticationToken(principalDetails, "", principalDetails.getAuthorities()); - return null; // TODO: update + MemberType memberType = getMemberType(token); + + Member member = memberRepository.findMemberByEmailAndMemberTypeAndDeletedAtIsNull(email, memberType) + .orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION)); + + PrincipalDetails principalDetails = new PrincipalDetails(member); + + return new UsernamePasswordAuthenticationToken(principalDetails, "", principalDetails.getAuthorities()); } /** @@ -128,28 +119,14 @@ public String getEmail(String token) { .getSubject(); } - /** - * 토큰의 만료기한 반환 - * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 - * @return 해당 토큰의 만료정보를 반환 - */ - public Date getExpiration(String token) { - return Jwts.parser() + public MemberType getMemberType(String token) { + String memberTypeStr = Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload() - .getExpiration(); - } + .get("memberType", String.class); - /** - * Claims 정보 생성 - * @param member - 사용자 정보 중 사용자를 구분할 수 있는 정보 두 개를 활용함 - * @return 사용자 구분 정보인 이메일과 역할을 저장한 Claims 객체 반환 - */ - private Claims getClaims(Member member) { - return Jwts.claims() - .subject(member.getEmail()) - .build(); + return MemberType.valueOf(memberTypeStr); } } diff --git a/api-module/src/main/java/hongik/triple/apimodule/presentation/member/MemberController.java b/api-module/src/main/java/hongik/triple/apimodule/presentation/member/MemberController.java index 006d920..f46ff26 100644 --- a/api-module/src/main/java/hongik/triple/apimodule/presentation/member/MemberController.java +++ b/api-module/src/main/java/hongik/triple/apimodule/presentation/member/MemberController.java @@ -5,58 +5,85 @@ import hongik.triple.apimodule.global.security.PrincipalDetails; import hongik.triple.commonmodule.dto.member.MemberReq; import hongik.triple.commonmodule.dto.member.MemberRes; +import hongik.triple.commonmodule.enumerate.MemberType; +import hongik.triple.inframodule.oauth.google.GoogleProfile; +import hongik.triple.inframodule.oauth.kakao.KakaoProfile; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/v1/member") +@RequestMapping("/api/v1") @RequiredArgsConstructor @Tag(name = "Member", description = "회원 관련 API") public class MemberController { private final MemberService memberService; - /** - * 로그인/회원가입 API - OAuth2 제공자에 따라 진행되며, 기존 로그인 정보 유무에 따라 회원가입 또는 로그인 처리 - * @param provider OAuth2 제공자 (예: kakao, google 등) - * @return 회원 정보 응답 (MemberRes) - */ - @PostMapping("/login") - public ApplicationResponse login(@RequestParam(name = "provider") String provider, - @RequestParam(name = "redirect-uri") String redirectUri) { - // 회원가입 로직 - if(provider.equals("kakao")) { - // 카카오 로그인 로직 - return ApplicationResponse.ok(memberService.loginWithKakao(provider, redirectUri)); - } else if(provider.equals("google")) { - // 구글 로그인 로직 - return ApplicationResponse.ok(memberService.loginWithGoogle(provider, redirectUri)); + @GetMapping("/auth/login") + public ResponseEntity redirectLoginPage( + @RequestParam(name = "provider") String provider, + @RequestParam(name = "redirect-uri", required = false) String redirectUri) { + String authUrl = switch (provider) { + case "kakao" -> memberService.getKakaoLoginUrl(redirectUri); + case "google" -> memberService.getGoogleLoginUrl(redirectUri); + default -> throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다."); + }; + + if(redirectUri == null || redirectUri.isEmpty()) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Location", authUrl); + return new ResponseEntity<>(headers, HttpStatus.FOUND); } else { - throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다."); + return ResponseEntity.ok(ApplicationResponse.ok(authUrl)); } } - @PostMapping("/withdrawal") - public void withdrawal(@AuthenticationPrincipal PrincipalDetails principalDetails) { - // 회원탈퇴 로직 - memberService.withdrawal(principalDetails.getMember()); + /** + * 카카오 로그인/회원가입 API - 기존 로그인 정보 유무에 따라 회원가입 또는 로그인 처리 + * @return 회원 정보 응답 (MemberRes) + */ + @GetMapping("/auth/kakao/login") + public ApplicationResponse loginWithKakao( + @RequestParam(name = "code") String authorizationCode, + @RequestParam(name = "redirect-uri", required = false) String redirectUri) { + KakaoProfile kakaoProfile = memberService.loginWithKakao(authorizationCode, redirectUri); + return ApplicationResponse.ok(memberService.register(kakaoProfile.kakao_account().email(), kakaoProfile.properties().nickname(), MemberType.KAKAO)); } + /** + * 구글 로그인/회원가입 API - 기존 로그인 정보 유무에 따라 회원가입 또는 로그인 처리 + * @return 회원 정보 응답 (MemberRes) + */ + @GetMapping("/auth/google/login") + public ApplicationResponse loginWithGoogle( + @RequestParam(name = "code") String authorizationCode, + @RequestParam(name = "redirect-uri", required = false) String redirectUri) { + GoogleProfile googleProfile = memberService.loginWithGoogle(authorizationCode, redirectUri); + return ApplicationResponse.ok(memberService.register(googleProfile.email(), googleProfile.name(), MemberType.GOOGLE)); + } - @PostMapping("/logout") - public void logout(@AuthenticationPrincipal PrincipalDetails principalDetails) { - memberService.logout(); + @PostMapping("/member/withdrawal") + public void withdrawal( + @AuthenticationPrincipal PrincipalDetails principalDetails) { + // 회원탈퇴 로직 + memberService.withdrawal(principalDetails.getMember()); } - @PostMapping("/profile") - public void getProfile(@AuthenticationPrincipal PrincipalDetails principalDetails) { - memberService.getProfile(principalDetails.getMember()); + @GetMapping("/member/profile") + public ApplicationResponse getProfile( + @AuthenticationPrincipal PrincipalDetails principalDetails) { + return ApplicationResponse.ok(memberService.getProfile(principalDetails.getMember())); } - @PatchMapping("/update") - public void updateProfile(@AuthenticationPrincipal PrincipalDetails principalDetails, @RequestBody MemberReq req) { + @PatchMapping("/member/update") + public void updateProfile( + @AuthenticationPrincipal PrincipalDetails principalDetails, + @RequestBody MemberReq req) { memberService.updateProfile(principalDetails.getMember(), req); } } diff --git a/api-module/src/test/java/hongik/triple/apimodule/member/MemberServiceTest.java b/api-module/src/test/java/hongik/triple/apimodule/member/MemberServiceTest.java index 7e17054..53f9adb 100644 --- a/api-module/src/test/java/hongik/triple/apimodule/member/MemberServiceTest.java +++ b/api-module/src/test/java/hongik/triple/apimodule/member/MemberServiceTest.java @@ -1,14 +1,17 @@ package hongik.triple.apimodule.member; import hongik.triple.apimodule.application.member.MemberService; -import hongik.triple.commonmodule.dto.member.MemberReq; +import hongik.triple.apimodule.global.security.jwt.TokenProvider; +import hongik.triple.apimodule.global.security.jwt.TokenDto; import hongik.triple.commonmodule.dto.member.MemberRes; +import hongik.triple.commonmodule.enumerate.MemberType; import hongik.triple.domainmodule.domain.member.Member; import hongik.triple.domainmodule.domain.member.repository.MemberRepository; import hongik.triple.inframodule.oauth.google.GoogleClient; import hongik.triple.inframodule.oauth.google.GoogleProfile; import hongik.triple.inframodule.oauth.google.GoogleToken; import hongik.triple.inframodule.oauth.kakao.KakaoClient; +import hongik.triple.inframodule.oauth.kakao.KakaoProfile; import hongik.triple.inframodule.oauth.kakao.KakaoToken; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -40,116 +43,208 @@ public class MemberServiceTest { @Mock private GoogleClient googleClient; + @Mock + private TokenProvider tokenProvider; + @InjectMocks private MemberService memberService; @Nested - @DisplayName("register()는") - class RegisterTest { + @DisplayName("getKakaoLoginUrl()은") + class GetKakaoLoginUrlTest { @Test - @DisplayName("회원이 없을 경우 새로 등록한다.") - void registerNewMember() { + @DisplayName("카카오 로그인 URL을 반환한다.") + void success() { // given - String email = "new@user.com"; - String name = "NewUser"; - GoogleToken googleToken = new GoogleToken( - "access_token", - "3600", - "Bearer", - "profile email", - "refresh_token", - "id_token" + String redirectUri = "http://localhost:3000/auth/callback"; + String expectedUrl = "https://kauth.kakao.com/oauth/authorize?client_id=xxx&redirect_uri=" + redirectUri + "&response_type=code"; + + given(kakaoClient.getKakaoAuthUrl(redirectUri)) + .willReturn(expectedUrl); + + // when + String result = memberService.getKakaoLoginUrl(redirectUri); + + // then + assertThat(result).isEqualTo(expectedUrl); + verify(kakaoClient, times(1)).getKakaoAuthUrl(redirectUri); + } + } + + @Nested + @DisplayName("getGoogleLoginUrl()은") + class GetGoogleLoginUrlTest { + + @Test + @DisplayName("구글 로그인 URL을 반환한다.") + void success() { + // given + String redirectUri = "http://localhost:3000/auth/callback"; + String expectedUrl = "https://accounts.google.com/o/oauth2/v2/auth?client_id=xxx&redirect_uri=" + redirectUri + "&response_type=code&scope=email profile"; + + given(googleClient.getGoogleAuthUrl(redirectUri)) + .willReturn(expectedUrl); + + // when + String result = memberService.getGoogleLoginUrl(redirectUri); + + // then + assertThat(result).isEqualTo(expectedUrl); + verify(googleClient, times(1)).getGoogleAuthUrl(redirectUri); + } + } + + @Nested + @DisplayName("loginWithKakao()는") + class LoginWithKakaoTest { + + @Test + @DisplayName("카카오 액세스 토큰과 사용자 정보를 정상적으로 조회한다.") + void success() { + // given + String authCode = "kakao_auth_code"; + String redirectUri = "http://localhost:3000/auth/callback"; + + KakaoToken kakaoToken = KakaoToken.builder() + .access_token("kakao_access_token") + .refresh_token("kakao_refresh_token") + .token_type("bearer") + .expires_in(3600) + .build(); + + KakaoProfile.Properties properties = new KakaoProfile.Properties( + "카카오유저", + "https://example.com/profile.jpg", + "https://example.com/thumbnail.jpg" ); - GoogleProfile googleProfile = new GoogleProfile( - "sub_id", - name, - "given", - "family", - "profile.jpg", - email, + + KakaoProfile.KakaoAccount.Profile profile = new KakaoProfile.KakaoAccount.Profile( + "카카오유저", + "https://example.com/thumbnail.jpg", + "https://example.com/profile.jpg", + false, + false + ); + + KakaoProfile.KakaoAccount kakaoAccount = new KakaoProfile.KakaoAccount( + false, + false, + profile, + true, + false, true, - "ko" + true, + "kakao@test.com" ); - Member member = new Member(name, email); - given(googleClient.getGoogleAccessToken(eq("access_token"), anyString())) - .willReturn(googleToken); - given(googleClient.getMemberInfo(eq(googleToken))) - .willReturn(googleProfile); - given(memberRepository.findByEmail(email)) - .willReturn(Optional.empty()); - given(memberRepository.save(any(Member.class))) - .willReturn(member); + KakaoProfile kakaoProfile = new KakaoProfile( + 12345L, + "2024-01-01T00:00:00Z", + properties, + kakaoAccount + ); + + given(kakaoClient.getKakaoAccessToken(authCode, redirectUri)) + .willReturn(kakaoToken); + given(kakaoClient.getMemberInfo(kakaoToken)) + .willReturn(kakaoProfile); // when - MemberRes result = memberService.loginWithGoogle("access_token", "http://localhost/oauth2/callback/google"); + KakaoProfile result = memberService.loginWithKakao(authCode, redirectUri); // then - assertThat(result.email()).isEqualTo(email); - assertThat(result.name()).isEqualTo(name); + assertThat(result.kakao_account().email()).isEqualTo("kakao@test.com"); + assertThat(result.properties().nickname()).isEqualTo("카카오유저"); + assertThat(result.kakao_account().profile().nickname()).isEqualTo("카카오유저"); + verify(kakaoClient, times(1)).getKakaoAccessToken(authCode, redirectUri); + verify(kakaoClient, times(1)).getMemberInfo(kakaoToken); } @Test - @DisplayName("회원이 이미 존재하면 기존 회원을 반환한다.") - void registerExistingMember() { + @DisplayName("카카오 사용자 정보 조회 중 예외가 발생하면 예외를 던진다.") + void throwsExceptionWhenGettingProfile() { // given - String email = "exist@user.com"; - String name = "Existing"; - Member existing = new Member(email, name); // ← 순서 확인 (email, name) + String authCode = "invalid_code"; + String redirectUri = "http://localhost:3000/auth/callback"; + + KakaoToken kakaoToken = KakaoToken.builder() + .access_token("kakao_access_token") + .build(); + + given(kakaoClient.getKakaoAccessToken(authCode, redirectUri)) + .willReturn(kakaoToken); + given(kakaoClient.getMemberInfo(kakaoToken)) + .willThrow(new RuntimeException("카카오 사용자 정보 조회 실패")); + + // when & then + assertThatThrownBy(() -> memberService.loginWithKakao(authCode, redirectUri)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("카카오 사용자 정보"); + } + } + + @Nested + @DisplayName("loginWithGoogle()는") + class LoginWithGoogleTest { + + @Test + @DisplayName("구글 액세스 토큰과 사용자 정보를 정상적으로 조회한다.") + void success() { + // given + String authCode = "google_auth_code"; + String redirectUri = "http://localhost:3000/auth/callback"; + GoogleToken googleToken = new GoogleToken( - "access_token", + "google_access_token", "3600", "Bearer", - "scope", - "refresh", + "profile email", + "google_refresh_token", "id_token" ); + GoogleProfile googleProfile = new GoogleProfile( - "sub", - name, - "given", - "family", - "picture.jpg", - email, + "google_sub_id", + "구글유저", + "구글", + "유저", + "profile.jpg", + "google@test.com", true, - "ko" + "ko", + null ); - given(googleClient.getGoogleAccessToken(eq("access_token"), anyString())) + given(googleClient.getGoogleAccessToken(authCode, redirectUri)) .willReturn(googleToken); - - given(googleClient.getMemberInfo(eq(googleToken))) + given(googleClient.getMemberInfo(googleToken)) .willReturn(googleProfile); - given(memberRepository.findByEmail(email)) - .willReturn(Optional.of(existing)); - // when - MemberRes result = memberService.loginWithGoogle("access_token", "http://localhost/oauth2/callback/google"); + GoogleProfile result = memberService.loginWithGoogle(authCode, redirectUri); // then - assertThat(result.email()).isEqualTo(existing.getEmail()); - assertThat(result.name()).isEqualTo(existing.getName()); + assertThat(result.email()).isEqualTo("google@test.com"); + assertThat(result.name()).isEqualTo("구글유저"); + verify(googleClient, times(1)).getGoogleAccessToken(authCode, redirectUri); + verify(googleClient, times(1)).getMemberInfo(googleToken); } @Test - @DisplayName("Google 사용자 정보 조회 중 예외가 발생하면 예외를 던진다.") - void getGoogleProfileThrowsException() { + @DisplayName("구글 사용자 정보 조회 중 예외가 발생하면 예외를 던진다.") + void throwsExceptionWhenGettingProfile() { // given - String authCode = "dummy_code"; - String redirectUri = "http://localhost/oauth2/callback/google"; + String authCode = "invalid_code"; + String redirectUri = "http://localhost:3000/auth/callback"; GoogleToken googleToken = new GoogleToken( - "dummy_access_token", "3600", "Bearer", "profile email", "dummy_refresh", "dummy_id_token" + "google_access_token", "3600", "Bearer", "profile email", "refresh", "id_token" ); - // getGoogleAccessToken: redirectUri 정확히 일치시켜야 함 - given(googleClient.getGoogleAccessToken(eq(authCode), eq(redirectUri))) + given(googleClient.getGoogleAccessToken(authCode, redirectUri)) .willReturn(googleToken); - - // getMemberInfo에서 예외 발생 - given(googleClient.getMemberInfo(eq(googleToken))) + given(googleClient.getMemberInfo(googleToken)) .willThrow(new RuntimeException("Failed to call Google API")); // when & then @@ -157,56 +252,82 @@ void getGoogleProfileThrowsException() { .isInstanceOf(RuntimeException.class) .hasMessageContaining("Google API"); } + } + + @Nested + @DisplayName("register()는") + class RegisterTest { @Test - @DisplayName("카카오 사용자 정보 조회 중 예외가 발생하면 예외를 던진다.") - void kakaoClientThrowsWhenGettingProfile() { + @DisplayName("신규 회원을 등록하고 토큰을 발급한다.") + void registerNewMember() { // given - String authCode = "dummy_auth_code"; - String redirectUri = "http://localhost/oauth2/callback/kakao"; + String email = "new@user.com"; + String nickname = "NewUser"; + MemberType memberType = MemberType.KAKAO; - KakaoToken kakaoToken = KakaoToken.builder() - .access_token("dummy_access_token") - .refresh_token("dummy_refresh_token") - .token_type("bearer") - .expires_in(3600) - .refresh_token_expires_in(1209600) - .scope("profile account_email") - .build(); + Member newMember = new Member(nickname, email, memberType); + TokenDto tokenDto = new TokenDto("generated_access_token"); - given(kakaoClient.getKakaoAccessToken(authCode, redirectUri)) - .willReturn(kakaoToken); + given(memberRepository.findByEmail(email)) + .willReturn(Optional.empty()); + given(memberRepository.save(any(Member.class))) + .willReturn(newMember); + given(tokenProvider.createToken(any(Member.class))) + .willReturn(tokenDto); - given(kakaoClient.getMemberInfo(kakaoToken)) - .willThrow(new RuntimeException("카카오 사용자 정보 조회 실패")); + // when + MemberRes result = memberService.register(email, nickname, memberType); - // when & then - assertThatThrownBy(() -> memberService.loginWithKakao(authCode, redirectUri)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("카카오 사용자 정보"); + // then + assertThat(result.email()).isEqualTo(email); + assertThat(result.name()).isEqualTo(nickname); + assertThat(result.accessToken()).isEqualTo("generated_access_token"); + verify(memberRepository, times(1)).save(any(Member.class)); } - } - - - @Nested - @DisplayName("updateProfile()는") - class UpdateProfileTest { @Test - @DisplayName("정상적으로 회원 정보를 수정한다.") - void success() { + @DisplayName("기존 회원이 존재하면 토큰만 재발급한다.") + void registerExistingMember() { // given - Member member = new Member("email@test.com", "old"); - MemberReq req = new MemberReq("new", "OILY"); + String email = "exist@user.com"; + String nickname = "ExistingUser"; + MemberType memberType = MemberType.GOOGLE; - given(memberRepository.save(any(Member.class))) - .willReturn(member); // save 이후 리턴값 지정 + Member existingMember = new Member(nickname, email, memberType); + TokenDto tokenDto = new TokenDto("new_access_token"); + + given(memberRepository.findByEmail(email)) + .willReturn(Optional.of(existingMember)); + given(tokenProvider.createToken(existingMember)) + .willReturn(tokenDto); // when - MemberRes res = memberService.updateProfile(member, req); + MemberRes result = memberService.register(email, nickname, memberType); // then - assertThat(res.name()).isEqualTo("new"); + assertThat(result.email()).isEqualTo(email); + assertThat(result.name()).isEqualTo(nickname); + assertThat(result.accessToken()).isEqualTo("new_access_token"); + verify(memberRepository, times(0)).save(any(Member.class)); // 저장 안 함 + } + + @Test + @DisplayName("이메일이 null이면 예외를 던진다.") + void throwsExceptionWhenEmailIsNull() { + // when & then + assertThatThrownBy(() -> memberService.register(null, "nickname", MemberType.KAKAO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Email"); + } + + @Test + @DisplayName("닉네임이 비어있으면 예외를 던진다.") + void throwsExceptionWhenNicknameIsEmpty() { + // when & then + assertThatThrownBy(() -> memberService.register("email@test.com", "", MemberType.KAKAO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Nickname"); } } @@ -217,15 +338,42 @@ class GetProfileTest { @Test @DisplayName("회원 정보를 반환한다.") void success() { - Member member = new Member("nick", "email@test.com"); + // given + Member member = new Member("nickname", "email@test.com", MemberType.KAKAO); - MemberRes res = memberService.getProfile(member); + // when + MemberRes result = memberService.getProfile(member); - assertThat(res.email()).isEqualTo("email@test.com"); - assertThat(res.name()).isEqualTo("nick"); + // then + assertThat(result.email()).isEqualTo("email@test.com"); + assertThat(result.name()).isEqualTo("nickname"); } } + // TODO: updateProfile() 메서드에 skin_type 필드 추가 후 테스트 케이스 수정 +// @Nested +// @DisplayName("updateProfile()은") +// class UpdateProfileTest { +// +// @Test +// @DisplayName("회원 정보를 정상적으로 수정한다.") +// void success() { +// // given +// Member member = new Member("oldName", "email@test.com", MemberType.KAKAO); +// MemberReq req = new MemberReq("newName", "OILY"); +// +// given(memberRepository.save(any(Member.class))) +// .willReturn(member); +// +// // when +// MemberRes result = memberService.updateProfile(member, req); +// +// // then +// assertThat(result.name()).isEqualTo("newName"); +// verify(memberRepository, times(1)).save(member); +// } +// } + @Nested @DisplayName("withdrawal()은") class WithdrawalTest { @@ -234,11 +382,12 @@ class WithdrawalTest { @DisplayName("회원 탈퇴를 정상적으로 수행한다.") void success() { // given - Member member = new Member("email@test.com", "nick"); + Member member = new Member("nickname", "email@test.com", MemberType.GOOGLE); // when memberService.withdrawal(member); + // then verify(memberRepository, times(1)).delete(member); } } diff --git a/common-module/src/main/java/hongik/triple/commonmodule/dto/member/MemberRes.java b/common-module/src/main/java/hongik/triple/commonmodule/dto/member/MemberRes.java index 964faee..a1886d9 100644 --- a/common-module/src/main/java/hongik/triple/commonmodule/dto/member/MemberRes.java +++ b/common-module/src/main/java/hongik/triple/commonmodule/dto/member/MemberRes.java @@ -1,8 +1,10 @@ package hongik.triple.commonmodule.dto.member; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; @Builder +@JsonInclude(JsonInclude.Include.NON_NULL) public record MemberRes( Long id, String email, @@ -11,6 +13,7 @@ public record MemberRes( String thumbnailImageUrl, String nickname, String profileImagePath, - String thumbnailImagePath + String thumbnailImagePath, + String accessToken // JWT Access Token ) { } diff --git a/common-module/src/main/java/hongik/triple/commonmodule/enumerate/MemberType.java b/common-module/src/main/java/hongik/triple/commonmodule/enumerate/MemberType.java new file mode 100644 index 0000000..5b3c66c --- /dev/null +++ b/common-module/src/main/java/hongik/triple/commonmodule/enumerate/MemberType.java @@ -0,0 +1,6 @@ +package hongik.triple.commonmodule.enumerate; + +public enum MemberType { + KAKAO, + GOOGLE +} \ No newline at end of file diff --git a/domain-module/src/main/java/hongik/triple/domainmodule/common/BaseTimeEntity.java b/domain-module/src/main/java/hongik/triple/domainmodule/common/BaseTimeEntity.java index 82c9a08..55b9a79 100644 --- a/domain-module/src/main/java/hongik/triple/domainmodule/common/BaseTimeEntity.java +++ b/domain-module/src/main/java/hongik/triple/domainmodule/common/BaseTimeEntity.java @@ -16,13 +16,13 @@ public class BaseTimeEntity { @CreatedDate - @Column(name = "createdAt", updatable = false, nullable = false) + @Column(name = "created_at", updatable = false, nullable = false) private LocalDateTime createdAt; @LastModifiedDate - @Column(name = "modifiedAt", nullable = false) + @Column(name = "modified_at", nullable = false) private LocalDateTime modifiedAt; - @Column(name = "deletedAt") + @Column(name = "deleted_at") private LocalDateTime deletedAt; } diff --git a/domain-module/src/main/java/hongik/triple/domainmodule/config/JpaConfig.java b/domain-module/src/main/java/hongik/triple/domainmodule/config/JpaConfig.java new file mode 100644 index 0000000..e1fff3e --- /dev/null +++ b/domain-module/src/main/java/hongik/triple/domainmodule/config/JpaConfig.java @@ -0,0 +1,19 @@ +package hongik.triple.domainmodule.config; + +import hongik.triple.domainmodule.AcneLogDomainRoot; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@EnableJpaAuditing +@EnableJpaRepositories(basePackageClasses = {AcneLogDomainRoot.class}) +@EntityScan(basePackageClasses = {AcneLogDomainRoot.class}) +@Configuration +public class JpaConfig { + + @PersistenceContext + private EntityManager em; +} \ No newline at end of file diff --git a/domain-module/src/main/java/hongik/triple/domainmodule/domain/member/Member.java b/domain-module/src/main/java/hongik/triple/domainmodule/domain/member/Member.java index 9fcd97b..1e10e62 100644 --- a/domain-module/src/main/java/hongik/triple/domainmodule/domain/member/Member.java +++ b/domain-module/src/main/java/hongik/triple/domainmodule/domain/member/Member.java @@ -1,5 +1,6 @@ package hongik.triple.domainmodule.domain.member; +import hongik.triple.commonmodule.enumerate.MemberType; import hongik.triple.domainmodule.common.BaseTimeEntity; import jakarta.persistence.*; import lombok.Getter; @@ -16,19 +17,28 @@ public class Member extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long memberId; + + @Column(name = "name", nullable = false) private String name; + + @Column(name = "email", nullable = false, unique = true) private String email; - private String memberType; - private String provider; - private String skinType; - public void update(String name, String skinType) { - this.name = name; + @Column(name = "member_type", nullable = false) + @Enumerated(EnumType.STRING) + private MemberType memberType; + + @Column(name = "skin_type") + private String skinType; // SkinType enum의 값을 문자열로 저장 + + public void updateSkinType(String skinType) { this.skinType = skinType; } - public Member(String name, String email) { + public Member(String name, String email, MemberType memberType) { this.name = name; this.email = email; + this.memberType = memberType; + this.skinType = "normal"; // 기본값 설정 } } diff --git a/domain-module/src/main/java/hongik/triple/domainmodule/domain/member/repository/MemberRepository.java b/domain-module/src/main/java/hongik/triple/domainmodule/domain/member/repository/MemberRepository.java index 5864e87..a79b7b7 100644 --- a/domain-module/src/main/java/hongik/triple/domainmodule/domain/member/repository/MemberRepository.java +++ b/domain-module/src/main/java/hongik/triple/domainmodule/domain/member/repository/MemberRepository.java @@ -1,5 +1,6 @@ package hongik.triple.domainmodule.domain.member.repository; +import hongik.triple.commonmodule.enumerate.MemberType; import hongik.triple.domainmodule.domain.member.Member; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -11,4 +12,7 @@ public interface MemberRepository extends JpaRepository { // 이메일로 회원 조회 Optional findByEmail(String email); + + // 이메일과 회원 유형으로 회원 조회 + Optional findMemberByEmailAndMemberTypeAndDeletedAtIsNull(String email, MemberType memberType); } diff --git a/infra-module/src/main/java/hongik/triple/inframodule/oauth/google/GoogleClient.java b/infra-module/src/main/java/hongik/triple/inframodule/oauth/google/GoogleClient.java index 6770fc2..12ac1fc 100644 --- a/infra-module/src/main/java/hongik/triple/inframodule/oauth/google/GoogleClient.java +++ b/infra-module/src/main/java/hongik/triple/inframodule/oauth/google/GoogleClient.java @@ -30,10 +30,29 @@ public class GoogleClient { @Value("${spring.security.oauth2.client.provider.google.user-info-uri}") private String googleUserInfoUri; + @Value("${spring.security.oauth2.client.registration.google.redirect-uri}") + private String googleRedirectUri; + + public String getGoogleAuthUrl(String redirectUri) { + if(redirectUri == null || redirectUri.isEmpty()) { + redirectUri = googleRedirectUri; + } + + return "https://accounts.google.com/o/oauth2/v2/auth?client_id=" + googleClientId + + "&redirect_uri=" + redirectUri + + "&response_type=code" + + "&scope=email profile"; + } + /** * 인가코드 기반으로 Google access token 발급 */ public GoogleToken getGoogleAccessToken(String code, String redirectUri) { + // 별도의 리다이렉트 요청 URI 설정이 없을 경우, application.yml에 설정된 값 사용 + if (redirectUri == null || redirectUri.isEmpty()) { + redirectUri = googleRedirectUri; + } + WebClient webClient = WebClient.create(googleTokenUri); MultiValueMap params = new LinkedMultiValueMap<>(); diff --git a/infra-module/src/main/java/hongik/triple/inframodule/oauth/google/GoogleProfile.java b/infra-module/src/main/java/hongik/triple/inframodule/oauth/google/GoogleProfile.java index c105212..34ebd0a 100644 --- a/infra-module/src/main/java/hongik/triple/inframodule/oauth/google/GoogleProfile.java +++ b/infra-module/src/main/java/hongik/triple/inframodule/oauth/google/GoogleProfile.java @@ -8,5 +8,6 @@ public record GoogleProfile( String picture, String email, boolean email_verified, - String locale + String locale, + String hd // hosted domain 추가 ) {} \ No newline at end of file diff --git a/infra-module/src/main/java/hongik/triple/inframodule/oauth/kakao/KakaoClient.java b/infra-module/src/main/java/hongik/triple/inframodule/oauth/kakao/KakaoClient.java index 7608f6f..25de8bb 100644 --- a/infra-module/src/main/java/hongik/triple/inframodule/oauth/kakao/KakaoClient.java +++ b/infra-module/src/main/java/hongik/triple/inframodule/oauth/kakao/KakaoClient.java @@ -30,12 +30,30 @@ public class KakaoClient { @Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}") private String kakaoUserInfoUri; + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") + private String kakaoRedirectUri; + + public String getKakaoAuthUrl(String redirectUri) { + if(redirectUri == null || redirectUri.isEmpty()) { + redirectUri = kakaoRedirectUri; + } + + return "https://kauth.kakao.com/oauth/authorize?client_id=" + kakaoClientId + + "&redirect_uri=" + redirectUri + + "&response_type=code"; + } + /** * 카카오 서버에 인가코드 기반으로 사용자의 토큰 정보를 조회하는 메소드 * @param code - 카카오에서 발급해준 인가 코드 * @return - 카카오에서 반환한 응답 토큰 객체 */ public KakaoToken getKakaoAccessToken(String code, String redirectUri) { + // 별도의 리다이렉트 요청 URI 설정이 없을 경우, application.yml에 설정된 값 사용 + if (redirectUri == null || redirectUri.isEmpty()) { + redirectUri = kakaoRedirectUri; + } + // 요청 보낼 객체 기본 생성 WebClient webClient = WebClient.create(kakaoTokenUri); @@ -86,7 +104,6 @@ public KakaoProfile getMemberInfo(KakaoToken kakaoToken) { KakaoProfile kakaoProfile; try { kakaoProfile = objectMapper.readValue(response, KakaoProfile.class); - } catch (Exception e) { throw new RuntimeException(e); } diff --git a/infra-module/src/main/java/hongik/triple/inframodule/oauth/kakao/KakaoProfile.java b/infra-module/src/main/java/hongik/triple/inframodule/oauth/kakao/KakaoProfile.java index a6d149e..020929d 100644 --- a/infra-module/src/main/java/hongik/triple/inframodule/oauth/kakao/KakaoProfile.java +++ b/infra-module/src/main/java/hongik/triple/inframodule/oauth/kakao/KakaoProfile.java @@ -1,8 +1,6 @@ package hongik.triple.inframodule.oauth.kakao; public record KakaoProfile( - // 2023년 12월까지 없었던 것으로 보이는 데이터인데, 현재 계속 조회됨. (포럼에 문의된 상황) - Boolean setPrivacyInfo, Long id, String connected_at, Properties properties,