diff --git a/.github/workflows/CI_dev_be_pull_request.yml b/.github/workflows/CI_dev_be_pull_request.yml new file mode 100644 index 000000000..033601916 --- /dev/null +++ b/.github/workflows/CI_dev_be_pull_request.yml @@ -0,0 +1,39 @@ +name: Spring Boot & Gradle CI Jobs (With. dev branches pull_request) + +on: + pull_request: + branches: [ dev ] + +jobs: + build: + # 실행 환경 (Git Runners 개인 서버) + runs-on: self-hosted + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'adopt' + + # application.yml 파일 설정 + - name: resources 폴더 생성 + run: | + mkdir -p ./backend/src/main/resources + + - name: yml 파일 생성 + run: | + echo "${{ secrets.APPLICATION_DEFAULT }}" > ./backend/src/main/resources/application.yml + echo "${{ secrets.APPLICATION_LOCAL }}" > ./backend/src/main/resources/application-local.yml + echo "${{ secrets.APPLICATION_TEST }}" > ./backend/src/main/resources/application-test.yml + + # gradlew를 실행시키기 위해 권한 부여 + - name: Gradlew에게 실행권한 부여 + run: chmod +x ./backend/gradlew + + # 멀티모듈 빌드하기 + - name: 멀티모듈 전체 빌드 + run: | + cd ./backend + ./gradlew clean build -x test diff --git a/backend/src/main/java/com/example/backend/auth/api/controller/auth/AuthController.java b/backend/src/main/java/com/example/backend/auth/api/controller/auth/AuthController.java index 434ee570e..74463ae5e 100644 --- a/backend/src/main/java/com/example/backend/auth/api/controller/auth/AuthController.java +++ b/backend/src/main/java/com/example/backend/auth/api/controller/auth/AuthController.java @@ -1,6 +1,7 @@ package com.example.backend.auth.api.controller.auth; import com.example.backend.auth.api.controller.auth.response.AuthLoginPageResponse; +import com.example.backend.auth.api.controller.auth.response.AuthLoginResponse; import com.example.backend.auth.api.service.auth.AuthService; import com.example.backend.auth.api.service.oauth.OAuthService; import com.example.backend.common.response.JsonResult; @@ -33,7 +34,7 @@ public JsonResult> loginPage() { } @GetMapping("/{platformType}/login") - public JsonResult login( + public JsonResult login( @PathVariable("platformType") UserPlatformType platformType, @RequestParam("code") String code, @RequestParam("state") String loginState) { @@ -41,13 +42,9 @@ public JsonResult login( TODO : state 값이 유효한지 검증하는 로직이 필요합니다. */ - /* - TODO : 로그인 API는 최종적으로 JWT 토큰이 담긴 AuthLoginResponse DTO를 반환해줘야 합니다. - */ - - authService.login(platformType, code, loginState); + AuthLoginResponse loginResponse = authService.login(platformType, code, loginState); - return JsonResult.successOf("로그인에 성공하였습니다."); + return JsonResult.successOf(loginResponse); } /* diff --git a/backend/src/main/java/com/example/backend/auth/api/service/auth/AuthService.java b/backend/src/main/java/com/example/backend/auth/api/service/auth/AuthService.java index b897e741b..4171e146a 100644 --- a/backend/src/main/java/com/example/backend/auth/api/service/auth/AuthService.java +++ b/backend/src/main/java/com/example/backend/auth/api/service/auth/AuthService.java @@ -1,46 +1,104 @@ package com.example.backend.auth.api.service.auth; import com.example.backend.auth.api.controller.auth.response.AuthLoginResponse; +import com.example.backend.auth.api.service.jwt.JwtService; +import com.example.backend.auth.api.service.jwt.JwtToken; import com.example.backend.auth.api.service.oauth.OAuthService; import com.example.backend.auth.api.service.oauth.response.OAuthResponse; +import com.example.backend.domain.define.user.User; import com.example.backend.domain.define.user.constant.UserPlatformType; +import com.example.backend.domain.define.user.constant.UserRole; import com.example.backend.domain.define.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; + @Slf4j @Service @Transactional(readOnly = true) @RequiredArgsConstructor public class AuthService { + private static final String ROLE_CLAIM = "role"; + private static final String NAME_CLAIM = "name"; + private static final String PROFILE_IMAGE_CLAIM = "profileImageUrl"; private final UserRepository userRepository; private final OAuthService oAuthService; + private final JwtService jwtService; + @Transactional public AuthLoginResponse login(UserPlatformType platformType, String code, String state) { OAuthResponse loginResponse = oAuthService.login(platformType, code, state); - log.info(">>>> {}님이 로그인하셨습니다.", loginResponse.getName()); + String name = loginResponse.getName(); + String profileImageUrl = loginResponse.getProfileImageUrl(); + String email = loginResponse.getEmail(); + + log.info(">>>> [ {}님이 로그인하셨습니다 ] <<<<", name); /* - TODO : OAuth 로그인 인증을 마쳤으니 우리 애플리케이션의 DB에도 존재하는 사용자인지 확인해야 합니다. - * 회원이 아닐 경우, 즉 회원가입이 필요한 신규 사용자의 경우 OAuthResponse를 바탕으로 DB에 등록해줍니다. + * OAuth 로그인 인증을 마쳤으니 우리 애플리케이션의 DB에도 존재하는 사용자인지 확인한다. + * 회원이 아닐 경우, 즉 회원가입이 필요한 신규 사용자의 경우 OAuthResponse를 바탕으로 DB에 등록해준다. */ + User findUser = userRepository.findByEmail(email) + .orElseGet(() -> { + User saveUser = User.builder() + .platformId(loginResponse.getPlatformId()) + .platformType(loginResponse.getPlatformType()) + .role(UserRole.UNAUTH) + .name(name) + .email(email) + .profileImageUrl(profileImageUrl) + .build(); + + log.info(">>>> [ UNAUTH 권한으로 사용자를 DB에 등록합니다. 이후 회원가입이 필요합니다 ] <<<<"); + return userRepository.save(saveUser); + }); + + // 기존 사용자의 경우 OAuth 사용자 정보(이름, 사진)가 변경되었으면 업데이트해준다. + findUser.updateProfile(name, profileImageUrl); /* - TODO : 기존 사용자의 경우 OAuth 사용자 정보가 변경되었을 수 있으므로 변경 사항을 업데이트해주는 로직이 필요합니다. + DB에 저장된 사용자 정보를 기반으로 JWT 토큰을 발급 + * JWT 토큰을 요청시에 담아 보내면 JWT 토큰 인증 필터에서 Security Context에 인증된 사용자로 등록 + TODO : JWT 재발급을 위한 Refresh 토큰은 Redis에서 관리할 예정입니다. */ + JwtToken jwtToken = generateJwtToken(findUser); + + // JWT 토큰과 권한 정보를 담아 반환 + return AuthLoginResponse.builder() + .accessToken(jwtToken.getAccessToken()) + .refreshToken(jwtToken.getRefreshToken()) + .role(findUser.getRole()) + .build(); + } + + private JwtToken generateJwtToken(User user) { + // JWT 토큰 생성을 위한 claims 생성 + HashMap claims = new HashMap<>(); + claims.put(ROLE_CLAIM, user.getRole().name()); + claims.put(NAME_CLAIM, user.getName()); + claims.put(PROFILE_IMAGE_CLAIM, user.getProfileImageUrl()); + + // Access Token 생성 + final String jwtAccessToken = jwtService.generateAccessToken(claims, user); + // 임시로 Refresh Token 생성 + final String jwtRefreshToken = "jwt-refresh-token"; + log.info(">>>> [ 사용자 {}님의 JWT 토큰이 발급되었습니다 ] <<<<", user.getName()); /* - TODO : DB에 저장된 사용자 정보를 기반으로 JWT 토큰을 발급해주는 로직이 필요합니다. - * JWT 토큰을 요청시에 담아 인증된 사용자임을 알립니다. - * JWT 토큰 인증 필터에서 Security에 인증된 사용자로 등록될 것입니다. - * JWT 재발급을 위한 Refresh 토큰은 Redis에서 관리할 예정입니다. + TODO : Refresh Token 생성, 저장 로직이 필요합니다. + * Redis DB를 연동해 Refresh Token을 저장, 관리할 예정입니다. */ - // TODO : JWT 토큰을 담아 최종적으로 AuthLoginResponse를 반환해줍니다. - return null; + return JwtToken.builder() + .accessToken(jwtAccessToken) + .refreshToken(jwtRefreshToken) + .build(); } /* diff --git a/backend/src/main/java/com/example/backend/auth/api/service/jwt/JwtToken.java b/backend/src/main/java/com/example/backend/auth/api/service/jwt/JwtToken.java new file mode 100644 index 000000000..be0f005bd --- /dev/null +++ b/backend/src/main/java/com/example/backend/auth/api/service/jwt/JwtToken.java @@ -0,0 +1,19 @@ +package com.example.backend.auth.api.service.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class JwtToken { + private String accessToken; + private String refreshToken; + + @Builder + public JwtToken(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } +} diff --git a/backend/src/main/java/com/example/backend/auth/api/service/oauth/OAuthService.java b/backend/src/main/java/com/example/backend/auth/api/service/oauth/OAuthService.java index da8228899..614ce3fb2 100644 --- a/backend/src/main/java/com/example/backend/auth/api/service/oauth/OAuthService.java +++ b/backend/src/main/java/com/example/backend/auth/api/service/oauth/OAuthService.java @@ -3,8 +3,15 @@ import com.example.backend.auth.api.controller.auth.response.AuthLoginPageResponse; import com.example.backend.auth.api.service.oauth.adapter.OAuthAdapter; import com.example.backend.auth.api.service.oauth.adapter.github.GithubAdapter; + +import com.example.backend.auth.api.service.oauth.adapter.kakao.KakaoAdapter; import com.example.backend.auth.api.service.oauth.builder.OAuthURLBuilder; import com.example.backend.auth.api.service.oauth.builder.github.GithubURLBuilder; +import com.example.backend.auth.api.service.oauth.builder.kakao.KakaoURLBuilder; + +import com.example.backend.auth.api.service.oauth.adapter.google.GoogleAdapter; +import com.example.backend.auth.api.service.oauth.builder.google.GoogleURLBuilder; + import com.example.backend.auth.api.service.oauth.response.OAuthResponse; import com.example.backend.domain.define.user.constant.UserPlatformType; import lombok.extern.slf4j.Slf4j; @@ -16,6 +23,8 @@ import java.util.stream.Collectors; import static com.example.backend.domain.define.user.constant.UserPlatformType.GITHUB; +import static com.example.backend.domain.define.user.constant.UserPlatformType.KAKAO; +import static com.example.backend.domain.define.user.constant.UserPlatformType.GOOGLE; @Slf4j @Service @@ -24,16 +33,30 @@ public class OAuthService { private Map adapterMap; // 플랫폼별 Adapter, URLBuilder 등록 - public OAuthService(GithubAdapter githubAdapter, GithubURLBuilder githubURLBuilder) { + + + public OAuthService(GithubAdapter githubAdapter, GithubURLBuilder githubURLBuilder, GoogleAdapter googleAdapter, GoogleURLBuilder googleURLBuilder , KakaoAdapter kakaoAdapter, KakaoURLBuilder kakaoURLBuilder) { this.adapterMap = new HashMap<>() {{ // 깃허브 플랫폼 추가 put(GITHUB, OAuthFactory.builder() .oAuthAdapter(githubAdapter) .oAuthURLBuilder(githubURLBuilder) .build()); + + // 카카오 플랫폼 추가 + put(KAKAO, OAuthFactory.builder() + .oAuthAdapter(kakaoAdapter) + .oAuthURLBuilder(kakaoURLBuilder) + .build()); + + // 구글 플랫폼 추가 + put(GOOGLE, OAuthFactory.builder() + .oAuthAdapter(googleAdapter) + .oAuthURLBuilder(googleURLBuilder) + .build()); + }}; } - // OAuth 2.0 로그인 페이지 생성 public List loginPage(String state) { // 지원하는 모든 플랫폼의 로그인 페이지를 생성해 반환한다. @@ -62,17 +85,16 @@ public OAuthResponse login(UserPlatformType platformType, String code, String st OAuthURLBuilder urlBuilder = factory.getOAuthURLBuilder(); OAuthAdapter adapter = factory.getOAuthAdapter(); - log.info(">>>> {} Login Start", platformType); + log.info(">>>> [ {} Login Start ] <<<<", platformType); // code, state를 이용해 Access Token 요청 URL 생성 String tokenUrl = urlBuilder.token(code, state); // Access Token 획득 String accessToken = adapter.getToken(tokenUrl); - // 사용자 프로필 조회 OAuthResponse userInfo = adapter.getProfile(accessToken); - log.info(">>>> {} Login Success", platformType); + log.info(">>>> [ {} Login Success ] <<<<", platformType); return userInfo; } diff --git a/backend/src/main/java/com/example/backend/auth/api/service/oauth/adapter/github/GithubAdapter.java b/backend/src/main/java/com/example/backend/auth/api/service/oauth/adapter/github/GithubAdapter.java index a70b84fcc..99b3b6d73 100644 --- a/backend/src/main/java/com/example/backend/auth/api/service/oauth/adapter/github/GithubAdapter.java +++ b/backend/src/main/java/com/example/backend/auth/api/service/oauth/adapter/github/GithubAdapter.java @@ -35,7 +35,7 @@ public String getToken(String tokenURL) { return token.getAccess_token(); } catch (RuntimeException e) { - log.error(">>>> [ Github Oauth 인증 에러 발생: {}", ExceptionMessage.OAUTH_INVALID_TOKEN_URL.getText()); + log.error(">>>> [ Github Oauth 인증 에러 발생: {} ] <<<<", ExceptionMessage.OAUTH_INVALID_TOKEN_URL.getText()); throw new OAuthException(ExceptionMessage.OAUTH_INVALID_TOKEN_URL); } } @@ -53,7 +53,7 @@ public OAuthResponse getProfile(String accessToken) { .profileImageUrl(profile.getAvatar_url()) .build(); } catch (RuntimeException e) { - log.error(">>>> [ Github Oauth 인증 에러 발생: {}", ExceptionMessage.OAUTH_INVALID_ACCESS_TOKEN.getText()); + log.error(">>>> [ Github Oauth 인증 에러 발생: {} ] <<<<", ExceptionMessage.OAUTH_INVALID_ACCESS_TOKEN.getText()); throw new OAuthException(ExceptionMessage.OAUTH_INVALID_ACCESS_TOKEN); } } diff --git a/backend/src/main/java/com/example/backend/auth/api/service/oauth/adapter/google/GoogleAdapter.java b/backend/src/main/java/com/example/backend/auth/api/service/oauth/adapter/google/GoogleAdapter.java new file mode 100644 index 000000000..2d553be4a --- /dev/null +++ b/backend/src/main/java/com/example/backend/auth/api/service/oauth/adapter/google/GoogleAdapter.java @@ -0,0 +1,59 @@ +package com.example.backend.auth.api.service.oauth.adapter.google; + +import com.example.backend.auth.api.service.oauth.adapter.OAuthAdapter; +import com.example.backend.auth.api.service.oauth.response.OAuthResponse; +import com.example.backend.common.exception.ExceptionMessage; +import com.example.backend.common.exception.oauth.OAuthException; +import com.example.backend.external.clients.oauth.google.GoogleProfileClients; +import com.example.backend.external.clients.oauth.google.GoogleTokenClients; +import com.example.backend.external.clients.oauth.google.response.GoogleProfileResponse; +import com.example.backend.external.clients.oauth.google.response.GoogleTokenResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import java.net.URI; +import static com.example.backend.domain.define.user.constant.UserPlatformType.GOOGLE; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GoogleAdapter implements OAuthAdapter { + + private final GoogleTokenClients googleTokenClients; + private final GoogleProfileClients googleProfileClients; + + @Override + public String getToken(String tokenURL) { + try { + GoogleTokenResponse token = googleTokenClients.getToken(URI.create(tokenURL)); + // URL로 액세스 토큰을 요청 + + // 만약 token이 null일 경우 예외처리 + if (token.getAccess_token() == null) { + throw new OAuthException(ExceptionMessage.OAUTH_INVALID_TOKEN_URL); + } + return token.getAccess_token(); + } catch (RuntimeException e) { + log.error(">>>> [ Google Oauth 인증 에러 발생: {}", ExceptionMessage.OAUTH_INVALID_TOKEN_URL.getText()); + throw new OAuthException(ExceptionMessage.OAUTH_INVALID_TOKEN_URL); + } + } + + @Override + public OAuthResponse getProfile(String accessToken) { + try { + GoogleProfileResponse profile = googleProfileClients.getProfile("Bearer " + accessToken); + + // 액세스 토큰을 사용하여 프로필 정보 요청 + return OAuthResponse.builder() + .platformId(profile.getSub()) + .platformType(GOOGLE) + .name(profile.getName()) + .profileImageUrl(profile.getPicture()) + .build(); + } catch (RuntimeException e) { + log.error(">>>> [ Google Oauth 인증 에러 발생: {}", ExceptionMessage.OAUTH_INVALID_ACCESS_TOKEN.getText()); + throw new OAuthException(ExceptionMessage.OAUTH_INVALID_ACCESS_TOKEN); + } + } +} diff --git a/backend/src/main/java/com/example/backend/auth/api/service/oauth/adapter/kakao/KakaoAdapter.java b/backend/src/main/java/com/example/backend/auth/api/service/oauth/adapter/kakao/KakaoAdapter.java new file mode 100644 index 000000000..702ead502 --- /dev/null +++ b/backend/src/main/java/com/example/backend/auth/api/service/oauth/adapter/kakao/KakaoAdapter.java @@ -0,0 +1,59 @@ +package com.example.backend.auth.api.service.oauth.adapter.kakao; + +import com.example.backend.auth.api.service.oauth.adapter.OAuthAdapter; +import com.example.backend.auth.api.service.oauth.response.OAuthResponse; +import com.example.backend.common.exception.ExceptionMessage; +import com.example.backend.common.exception.oauth.OAuthException; +import com.example.backend.external.clients.oauth.kakao.KakaoProfileClients; +import com.example.backend.external.clients.oauth.kakao.KakaoTokenClients; +import com.example.backend.external.clients.oauth.kakao.response.KakaoProfileResponse; +import com.example.backend.external.clients.oauth.kakao.response.KakaoTokenResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URI; + +import static com.example.backend.domain.define.user.constant.UserPlatformType.KAKAO; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KakaoAdapter implements OAuthAdapter { + private final KakaoTokenClients kakaoTokenClients; + private final KakaoProfileClients kakaoProfileClients; + + @Override + public String getToken(String tokenURL) { + try { + KakaoTokenResponse token = kakaoTokenClients.getToken(URI.create(tokenURL)); + + // 받아온 token이 null일 경우 예외 발생 + if (token.getAccess_token() == null) { + throw new OAuthException(ExceptionMessage.OAUTH_INVALID_TOKEN_URL); + } + + return token.getAccess_token(); + } catch (RuntimeException e) { + log.error(">>>> [ Kakao Oauth 인증 에러 발생: {}", ExceptionMessage.OAUTH_INVALID_TOKEN_URL.getText()); + throw new OAuthException(ExceptionMessage.OAUTH_INVALID_TOKEN_URL); + } + } + + @Override + public OAuthResponse getProfile(String accessToken) { + try { + KakaoProfileResponse profile = kakaoProfileClients.getProfile("Bearer " + accessToken); + + return OAuthResponse.builder() + .platformId(profile.getId().toString()) + .platformType(KAKAO) + .name(profile.getProperties().getNickname()) + .profileImageUrl(profile.getProperties().getProfile_image()) + .build(); + } catch (RuntimeException e) { + log.error(">>>> [ Kakao Oauth 인증 에러 발생: {}", ExceptionMessage.OAUTH_INVALID_ACCESS_TOKEN.getText()); + throw new OAuthException(ExceptionMessage.OAUTH_INVALID_ACCESS_TOKEN); + } + } +} diff --git a/backend/src/main/java/com/example/backend/auth/api/service/oauth/builder/github/GithubURLBuilder.java b/backend/src/main/java/com/example/backend/auth/api/service/oauth/builder/github/GithubURLBuilder.java index f861050eb..df17520ff 100644 --- a/backend/src/main/java/com/example/backend/auth/api/service/oauth/builder/github/GithubURLBuilder.java +++ b/backend/src/main/java/com/example/backend/auth/api/service/oauth/builder/github/GithubURLBuilder.java @@ -33,7 +33,7 @@ public GithubURLBuilder(OAuthProperties oAuthProperties) { this.profileUri = githubProvider.profileUri(); } catch (NullPointerException e) { - log.error(">>>> OAuthProperties NullPointerException 발생: {}", ExceptionMessage.OAUTH_CONFIG_NULL); + log.error(">>>> [ OAuthProperties NullPointerException 발생: {} ] <<<<", ExceptionMessage.OAUTH_CONFIG_NULL); throw new OAuthException(ExceptionMessage.OAUTH_CONFIG_NULL); } } diff --git a/backend/src/main/java/com/example/backend/auth/api/service/oauth/builder/google/GoogleURLBuilder.java b/backend/src/main/java/com/example/backend/auth/api/service/oauth/builder/google/GoogleURLBuilder.java new file mode 100644 index 000000000..4911655c7 --- /dev/null +++ b/backend/src/main/java/com/example/backend/auth/api/service/oauth/builder/google/GoogleURLBuilder.java @@ -0,0 +1,74 @@ +package com.example.backend.auth.api.service.oauth.builder.google; + +import com.example.backend.auth.api.service.oauth.builder.OAuthURLBuilder; +import com.example.backend.auth.config.oauth.OAuthProperties; +import com.example.backend.common.exception.ExceptionMessage; +import com.example.backend.common.exception.oauth.OAuthException; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Component; + + +@Slf4j +@Component +public class GoogleURLBuilder implements OAuthURLBuilder { + + private static final String PLATFORM = "google"; + private final String authorizationUri; + private final String clientId; + private final String redirectUri; + private final String tokenUri; + private final String clientSecret; + private final String profileUri; + + + public GoogleURLBuilder(OAuthProperties oAuthProperties) { + try { + // 플랫폼(google)의 client, provider Map 획득 + OAuthProperties.Client googleClient = oAuthProperties.getClient().get(PLATFORM); + OAuthProperties.Provider googleProvider = oAuthProperties.getProvider().get(PLATFORM); + + this.authorizationUri = googleProvider.authorizationUri(); + this.clientId = googleClient.clientId(); + this.redirectUri = googleClient.redirectUri(); + this.tokenUri = googleProvider.tokenUri(); + this.clientSecret = googleClient.clientSecret(); + this.profileUri = googleProvider.profileUri(); + + } catch (NullPointerException e) { + log.error(">>>> OAuthProperties NullPointerException 발생: {}", ExceptionMessage.OAUTH_CONFIG_NULL); + throw new OAuthException(ExceptionMessage.OAUTH_CONFIG_NULL); + } + } + + // "https://accounts.google.com/o/oauth2/v2/auth?..." + @Override // Google OAuth 인증을 위한 URL 생성 + public String authorize(String state) { + return authorizationUri + + "?response_type=code" // OAuth 인증 코드 그랜트 유형: code로 고정 + + "&client_id=" + clientId // 클라이언트 ID + + "&redirect_uri=" + redirectUri // 리다이렉트 URI + + "&state=" + state // CSRF 방지 + + "&scope=email+profile"; // Google의 경우 openid가 아닌 email+profile로 추가해야함 + } + + // "https://oauth2.googleapis.com/token?..." + @Override // access token을 요청하는 URL 생성 + public String token(String code, String state) { + return tokenUri + + "?grant_type=authorization_code" // OAuth 인증 코드 그랜트 유형: code로 고정 + + "&client_id=" + clientId // 클라이언트 ID + + "&client_secret=" + clientSecret // 클라이언트 Secret + + "&redirect_uri=" + redirectUri // 리다이렉트 URI + + "&code=" + code; // authorize() 요청으로 얻은 인가 코드 + } + + + + + // "https://www.googleapis.com/oauth2/v3/userinfo" + @Override // 사용자 프로필 정보 요청하는 URL반환 + public String profile() { + return profileUri; + } +} diff --git a/backend/src/main/java/com/example/backend/auth/api/service/oauth/builder/kakao/KakaoURLBuilder.java b/backend/src/main/java/com/example/backend/auth/api/service/oauth/builder/kakao/KakaoURLBuilder.java new file mode 100644 index 000000000..04a4b35a1 --- /dev/null +++ b/backend/src/main/java/com/example/backend/auth/api/service/oauth/builder/kakao/KakaoURLBuilder.java @@ -0,0 +1,66 @@ +package com.example.backend.auth.api.service.oauth.builder.kakao; + +import com.example.backend.auth.api.service.oauth.builder.OAuthURLBuilder; +import com.example.backend.auth.config.oauth.OAuthProperties; +import com.example.backend.common.exception.ExceptionMessage; +import com.example.backend.common.exception.oauth.OAuthException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class KakaoURLBuilder implements OAuthURLBuilder { + private static final String PLATFORM = "kakao"; + private final String authorizationUri; + private final String clientId; + private final String redirectUri; + private final String tokenUri; + private final String clientSecret; + private final String profileUri; + + + // 속성에서 읽어온 객체를 주입 + public KakaoURLBuilder(OAuthProperties oAuthProperties) { + try { + // 플랫폼(kakao)의 client, provider Map 획득 + OAuthProperties.Client kakaoClient = oAuthProperties.getClient().get(PLATFORM); + OAuthProperties.Provider kakaoProvider = oAuthProperties.getProvider().get(PLATFORM); + + this.authorizationUri = kakaoProvider.authorizationUri(); + this.clientId = kakaoClient.clientId(); + this.redirectUri = kakaoClient.redirectUri(); + this.tokenUri = kakaoProvider.tokenUri(); + this.clientSecret = kakaoClient.clientSecret(); + this.profileUri = kakaoProvider.profileUri(); + + } catch (NullPointerException e) { + log.error(">>>> OAuthProperties NullPointerException 발생: {}", ExceptionMessage.OAUTH_CONFIG_NULL); + throw new OAuthException(ExceptionMessage.OAUTH_CONFIG_NULL); + } + } + // "https://kauth.kakao.com/oauth/authorize?..." + @Override + public String authorize(String state) { + return authorizationUri + + "?response_type=code" // OAuth 인증 코드 그랜트 유형: code로 고정 + + "&client_id=" + clientId // 클라이언트 ID + + "&redirect_uri=" + redirectUri // 리다이렉트 URI + + "&state=" + state // CSRF 방지 + + "&scope=openid"; // 리소스 접근 범위: openid로 고정 + } + + // "https://kauth.kakao.com/oauth/token?..." + @Override + public String token(String code, String state) { + return tokenUri + + "?grant_type=authorization_code" // OAuth 인증 코드 그랜트 유형: code로 고정 + + "&client_id=" + clientId // 클라이언트 ID + + "&client_secret=" + clientSecret // 클라이언트 Secret + + "&redirect_uri=" + redirectUri // 리다이렉트 URI + + "&code=" + code; // authorize() 요청으로 얻은 인가 코드 + + } + // "https://kapi.kakao.com/v2/user/me" + @Override + public String profile() { return profileUri; } +} diff --git a/backend/src/main/java/com/example/backend/domain/define/user/User.java b/backend/src/main/java/com/example/backend/domain/define/user/User.java index 964989c2e..4670ddc7f 100644 --- a/backend/src/main/java/com/example/backend/domain/define/user/User.java +++ b/backend/src/main/java/com/example/backend/domain/define/user/User.java @@ -72,6 +72,11 @@ private User(String platformId, this.pushAlarmYn = pushAlarmYn; } + public void updateProfile(String name, String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + this.name = name; + } + // Spring Security UserDetails Area @Override public Collection getAuthorities() { diff --git a/backend/src/main/java/com/example/backend/external/clients/oauth/google/GoogleProfileClients.java b/backend/src/main/java/com/example/backend/external/clients/oauth/google/GoogleProfileClients.java new file mode 100644 index 000000000..d5a431273 --- /dev/null +++ b/backend/src/main/java/com/example/backend/external/clients/oauth/google/GoogleProfileClients.java @@ -0,0 +1,18 @@ +package com.example.backend.external.clients.oauth.google; + + +import com.example.backend.external.annotation.ExternalClients; +import com.example.backend.external.clients.oauth.google.response.GoogleProfileResponse; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; + +/* + 사용자 정보를 얻기 위해 설정한 profile 요청 URI로 GET 요청을 보낸다. + */ +@ExternalClients(baseUrl = "oauth2.provider.google.profile-uri") +public interface GoogleProfileClients { + + @GetExchange + public GoogleProfileResponse getProfile(@RequestHeader(value = "Authorization") String header); + +} diff --git a/backend/src/main/java/com/example/backend/external/clients/oauth/google/GoogleTokenClients.java b/backend/src/main/java/com/example/backend/external/clients/oauth/google/GoogleTokenClients.java new file mode 100644 index 000000000..3e9044773 --- /dev/null +++ b/backend/src/main/java/com/example/backend/external/clients/oauth/google/GoogleTokenClients.java @@ -0,0 +1,20 @@ +package com.example.backend.external.clients.oauth.google; + + +import com.example.backend.external.annotation.ExternalClients; +import com.example.backend.external.clients.oauth.google.response.GoogleTokenResponse; +import org.springframework.http.MediaType; +import org.springframework.web.service.annotation.PostExchange; + +import java.net.URI; + +/* + AccessToken을 얻기 위해 설정한 token URI로 POST 요청을 보낸다. + */ +@ExternalClients(baseUrl = "oauth2.provider.google.token-uri") +public interface GoogleTokenClients { + + // 요청의 content Type: FORM_URLENCODED 형식 ex) key1=value1&key2=value2 + @PostExchange(contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public GoogleTokenResponse getToken(URI uri); +} diff --git a/backend/src/main/java/com/example/backend/external/clients/oauth/google/response/GoogleProfileResponse.java b/backend/src/main/java/com/example/backend/external/clients/oauth/google/response/GoogleProfileResponse.java new file mode 100644 index 000000000..90a3c66db --- /dev/null +++ b/backend/src/main/java/com/example/backend/external/clients/oauth/google/response/GoogleProfileResponse.java @@ -0,0 +1,24 @@ +package com.example.backend.external.clients.oauth.google.response; + + +import lombok.Getter; +import lombok.NoArgsConstructor; + + +/* + Google에 사용자 정보를 요청 후 반환받을 DTO + */ +@Getter +@NoArgsConstructor // 사용자 프로필 응답 데이터 +public class GoogleProfileResponse { + private String sub; // Google에서 사용하는 사용자의 고유 식별자 (subject) + private String name; + private String picture; + + + public GoogleProfileResponse(String sub, String name, String picture) { + this.sub = sub; + this.name = name; + this.picture = picture; + } +} diff --git a/backend/src/main/java/com/example/backend/external/clients/oauth/google/response/GoogleTokenResponse.java b/backend/src/main/java/com/example/backend/external/clients/oauth/google/response/GoogleTokenResponse.java new file mode 100644 index 000000000..d07231574 --- /dev/null +++ b/backend/src/main/java/com/example/backend/external/clients/oauth/google/response/GoogleTokenResponse.java @@ -0,0 +1,20 @@ +package com.example.backend.external.clients.oauth.google.response; + + +import lombok.Getter; +import lombok.NoArgsConstructor; + +/* + Google에 Access Token을 요청 후 반환받을 DTO + */ +@Getter +@NoArgsConstructor +public class GoogleTokenResponse { + private String access_token; + + + public GoogleTokenResponse(String access_token) { + this.access_token = access_token; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/KakaoProfileClients.java b/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/KakaoProfileClients.java new file mode 100644 index 000000000..d0c756c44 --- /dev/null +++ b/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/KakaoProfileClients.java @@ -0,0 +1,17 @@ +package com.example.backend.external.clients.oauth.kakao; + +import com.example.backend.external.annotation.ExternalClients; +import com.example.backend.external.clients.oauth.kakao.response.KakaoProfileResponse; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; + +/* + 사용자 정보를 얻기 위해 설정한 profile 요청 URI로 GET 요청을 보낸다. + */ +@ExternalClients(baseUrl = "https://kapi.kakao.com/v2/user/me") +public interface KakaoProfileClients { + + @GetExchange + public KakaoProfileResponse getProfile(@RequestHeader(value = "Authorization") String header); + +} diff --git a/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/KakaoTokenClients.java b/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/KakaoTokenClients.java new file mode 100644 index 000000000..5ab731e37 --- /dev/null +++ b/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/KakaoTokenClients.java @@ -0,0 +1,20 @@ +package com.example.backend.external.clients.oauth.kakao; + + +import com.example.backend.external.annotation.ExternalClients; +import com.example.backend.external.clients.oauth.kakao.response.KakaoTokenResponse; +import org.springframework.http.MediaType; +import org.springframework.web.service.annotation.PostExchange; + +import java.net.URI; + +/* + AccessToken을 얻기 위해 설정한 token URI로 POST 요청을 보낸다. + */ +@ExternalClients(baseUrl = "oauth2.provider.kakao.token-uri") +public interface KakaoTokenClients { + + // 요청의 content Type: FORM_URLENCODED 형식 ex) key1=value1&key2=value2 + @PostExchange(contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public KakaoTokenResponse getToken(URI uri); +} diff --git a/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/response/KakaoProfileResponse.java b/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/response/KakaoProfileResponse.java new file mode 100644 index 000000000..8a20f5d08 --- /dev/null +++ b/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/response/KakaoProfileResponse.java @@ -0,0 +1,33 @@ +package com.example.backend.external.clients.oauth.kakao.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class KakaoProfileResponse { + private Long id; + private String name; + + private Properties properties; + + public KakaoProfileResponse(Long id, String name, Properties properties) { + this.id = id; + this.name = name; + this.properties = properties; + } + + @Getter + @NoArgsConstructor + public static class Properties { + private String nickname; + private String profile_image; + private String thumbnail_image; + + public Properties(String nickname, String profile_image, String thumbnail_image) { + this.nickname = nickname; + this.profile_image = profile_image; + this.thumbnail_image = thumbnail_image; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/response/KakaoTokenResponse.java b/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/response/KakaoTokenResponse.java new file mode 100644 index 000000000..be3e7b344 --- /dev/null +++ b/backend/src/main/java/com/example/backend/external/clients/oauth/kakao/response/KakaoTokenResponse.java @@ -0,0 +1,19 @@ +package com.example.backend.external.clients.oauth.kakao.response; + + +import lombok.Getter; +import lombok.NoArgsConstructor; + +/* + Kakao에 Access Token을 요청 후 반환받을 DTO + */ +@Getter +@NoArgsConstructor +public class KakaoTokenResponse { + private String access_token; + + public KakaoTokenResponse(String access_token) { + this.access_token = access_token; + } +} + diff --git a/backend/src/main/java/com/example/backend/external/config/ExternalClientsPostProcessor.java b/backend/src/main/java/com/example/backend/external/config/ExternalClientsPostProcessor.java index 092b18bcd..785eddd15 100644 --- a/backend/src/main/java/com/example/backend/external/config/ExternalClientsPostProcessor.java +++ b/backend/src/main/java/com/example/backend/external/config/ExternalClientsPostProcessor.java @@ -57,7 +57,7 @@ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) Object externalClientsBean = factory.createClient(clazz); beanFactory.registerSingleton(clazz.getSimpleName(), externalClientsBean); - log.info(">>>> Success External Clients : {}, baseUrl : {}", clazz.getSimpleName(), baseUrl); + log.info(">>>> [ Success External Clients : {}, baseUrl : {} ] <<<<", clazz.getSimpleName(), baseUrl); } } diff --git a/backend/src/test/java/com/example/backend/auth/TestConfig.java b/backend/src/test/java/com/example/backend/auth/TestConfig.java index 214e27c72..18a75776b 100644 --- a/backend/src/test/java/com/example/backend/auth/TestConfig.java +++ b/backend/src/test/java/com/example/backend/auth/TestConfig.java @@ -16,6 +16,7 @@ */ +import com.example.backend.auth.api.service.oauth.response.OAuthResponse; import com.example.backend.domain.define.user.User; import com.example.backend.domain.define.user.constant.UserPlatformType; import com.example.backend.domain.define.user.constant.UserRole; @@ -23,6 +24,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import static com.example.backend.domain.define.user.constant.UserPlatformType.GITHUB; + @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc @@ -41,5 +44,15 @@ public static User generateUser() { .pushAlarmYn(true) .build(); } + + public static OAuthResponse generateOauthResponse() { + return OAuthResponse.builder() + .platformId("1") + .platformType(GITHUB) + .email("32183520@dankook.ac.kr") + .name("jusung-c") + .profileImageUrl("http://www.naver.com") + .build(); + } } diff --git a/backend/src/test/java/com/example/backend/auth/api/service/auth/AuthServiceTest.java b/backend/src/test/java/com/example/backend/auth/api/service/auth/AuthServiceTest.java new file mode 100644 index 000000000..5dbb0f589 --- /dev/null +++ b/backend/src/test/java/com/example/backend/auth/api/service/auth/AuthServiceTest.java @@ -0,0 +1,135 @@ +package com.example.backend.auth.api.service.auth; + +import com.example.backend.auth.TestConfig; +import com.example.backend.auth.api.controller.auth.response.AuthLoginResponse; +import com.example.backend.auth.api.service.jwt.JwtService; +import com.example.backend.auth.api.service.oauth.OAuthService; +import com.example.backend.auth.api.service.oauth.response.OAuthResponse; +import com.example.backend.domain.define.user.User; +import com.example.backend.domain.define.user.constant.UserPlatformType; +import com.example.backend.domain.define.user.constant.UserRole; +import com.example.backend.domain.define.user.repository.UserRepository; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; + +import static com.example.backend.domain.define.user.constant.UserPlatformType.GITHUB; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +class AuthServiceTest extends TestConfig { + + @MockBean + private OAuthService oAuthService; + + @Autowired + private AuthService authService; + + @Autowired + private JwtService jwtService; + + @Autowired + private UserRepository userRepository; + + @AfterEach + void tearDown() { + userRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("신규 사용자의 경우 UNAUTH 권한으로 DB에 저장된다.") + void registerUnauthUser() { + // given + OAuthResponse oAuthResponse = generateOauthResponse(); + when(oAuthService.login(any(UserPlatformType.class), any(String.class), any(String.class))) + .thenReturn(oAuthResponse); + + // when + authService.login(GITHUB, "code", "state"); + + User user = userRepository.findByEmail(oAuthResponse.getEmail()).get(); + + // then + assertThat(user.getRole().name()).isEqualTo(UserRole.UNAUTH.name()); + } + + @Test + @DisplayName("OAuth 사용자 정보 변경시 DB에 업데이트되어야 한다.") + void loginUserProfileUpdate() { + // given + OAuthResponse oAuthResponse = generateOauthResponse(); + when(oAuthService.login(any(UserPlatformType.class), any(String.class), any(String.class))) + .thenReturn(oAuthResponse); + authService.login(GITHUB, "code", "state"); + + oAuthResponse = OAuthResponse.builder() + .platformId("1") + .platformType(GITHUB) + .email("32183520@dankook.ac.kr") + .name("testName") + .profileImageUrl("www.test.com") + .build(); + + when(oAuthService.login(any(UserPlatformType.class), any(String.class), any(String.class))) + .thenReturn(oAuthResponse); + + // when + authService.login(GITHUB, "code", "state"); + User findUser = userRepository.findByEmail(oAuthResponse.getEmail()).get(); + + // then + assertThat(findUser.getName()).isEqualTo("testName"); + assertThat(findUser.getProfileImageUrl()).isEqualTo("www.test.com"); + + + } + + @Test + @DisplayName("OAuth 로그인 인증 완료 후 JWT 토큰이 정상적으로 발급된다.") + void loginJwtTokenGenerate() { + // given + OAuthResponse oAuthResponse = generateOauthResponse(); + when(oAuthService.login(any(UserPlatformType.class), any(String.class), any(String.class))) + .thenReturn(oAuthResponse); + + // when + AuthLoginResponse loginResponse = authService.login(GITHUB, "code", "state"); +// System.out.println("Access Token: " + loginResponse.getAccessToken()); +// System.out.println("Refresh Token: " + loginResponse.getRefreshToken()); + + // then + assertThat(loginResponse).isNotNull(); + assertThat(loginResponse.getAccessToken()).isNotBlank(); + assertThat(loginResponse.getRefreshToken()).isNotBlank(); + } + + @Test + @DisplayName("OAuth 로그인 인증이 완료된 사용자의 JWT 토큰은 알맞은 Claims가 들어있어야 한다.") + void loginJwtTokenValidClaims() { + // given + String role = "role"; + String name = "name"; + String profileImageUrl = "profileImageUrl"; + + OAuthResponse oAuthResponse = generateOauthResponse(); + when(oAuthService.login(any(UserPlatformType.class), any(String.class), any(String.class))) + .thenReturn(oAuthResponse); + + // when + AuthLoginResponse loginResponse = authService.login(GITHUB, "code", "state"); + String atk = loginResponse.getAccessToken(); + Claims claims = jwtService.extractAllClaims(atk); + + // then + assertAll( + () -> assertThat(claims.get(role)).isEqualTo("UNAUTH"), + () -> assertThat(claims.get(name)).isEqualTo("jusung-c"), + () -> assertThat(claims.get(profileImageUrl)).isEqualTo("http://www.naver.com") + ); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/example/backend/auth/api/service/oauth/OAuthServiceTest.java b/backend/src/test/java/com/example/backend/auth/api/service/oauth/OAuthServiceTest.java index 3229c070b..b8c692101 100644 --- a/backend/src/test/java/com/example/backend/auth/api/service/oauth/OAuthServiceTest.java +++ b/backend/src/test/java/com/example/backend/auth/api/service/oauth/OAuthServiceTest.java @@ -2,10 +2,18 @@ import com.example.backend.auth.TestConfig; import com.example.backend.auth.api.controller.auth.response.AuthLoginPageResponse; +import com.example.backend.auth.api.service.jwt.JwtService; import com.example.backend.auth.api.service.oauth.adapter.github.GithubAdapter; +import com.example.backend.auth.api.service.oauth.adapter.kakao.KakaoAdapter; import com.example.backend.auth.api.service.oauth.builder.github.GithubURLBuilder; +import com.example.backend.auth.api.service.oauth.builder.kakao.KakaoURLBuilder; +import com.example.backend.auth.api.service.oauth.adapter.google.GoogleAdapter; +import com.example.backend.auth.api.service.oauth.builder.google.GoogleURLBuilder; import com.example.backend.auth.api.service.oauth.response.OAuthResponse; import com.example.backend.common.exception.oauth.OAuthException; +import com.example.backend.domain.define.user.constant.UserPlatformType; +import com.example.backend.domain.define.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -14,6 +22,8 @@ import java.util.List; import static com.example.backend.domain.define.user.constant.UserPlatformType.GITHUB; +import static com.example.backend.domain.define.user.constant.UserPlatformType.KAKAO; +import static com.example.backend.domain.define.user.constant.UserPlatformType.GOOGLE; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -29,6 +39,17 @@ class OAuthServiceTest extends TestConfig { @MockBean private GithubAdapter githubAdapter; + @Autowired + + private KakaoURLBuilder kakaoUrlBuilder; + @MockBean + private KakaoAdapter kakaoAdapter; + + private GoogleURLBuilder googleurlBuilder; + + @MockBean + private GoogleAdapter googleAdapter; + @Test @DisplayName("모든 플랫폼의 로그인 페이지를 성공적으로 반환한다.") void allUrlBuilderSuccess() { @@ -39,8 +60,32 @@ void allUrlBuilderSuccess() { List loginPages = oAuthService.loginPage(state); String authorizeURL = urlBuilder.authorize(state); + String authorizeURLkakao = kakaoUrlBuilder.authorize(state); // then - assertThat(loginPages.get(0).getUrl()).isEqualTo(authorizeURL); + + String authorizeURLgoogle = googleurlBuilder.authorize(state); + + // then + //assertThat(loginPages.get(0).getUrl()).isEqualTo(authorizeURL); + + assertThat(loginPages).hasSize(2); // 리스트 크기 확인 + + // 각 플랫폼별 URL인지 확인 + boolean containsGithub = loginPages.stream() + .anyMatch(page -> page.getPlatformType().equals(GITHUB) && + page.getUrl().equals(authorizeURL)); + boolean containsKakao = loginPages.stream() + .anyMatch(page -> page.getPlatformType().equals(KAKAO) && + page.getUrl().equals(authorizeURLkakao)); + + assertThat(containsGithub).isTrue(); + assertThat(containsKakao).isTrue(); + boolean containsGoogle = loginPages.stream() + .anyMatch(page -> page.getPlatformType().equals(GOOGLE) && + page.getUrl().equals(authorizeURLgoogle)); + + assertThat(containsGithub).isTrue(); + assertThat(containsGoogle).isTrue(); } @Test @@ -50,13 +95,7 @@ void githubLoginSuccess() { String code = "valid-code"; String state = "valid-state"; - OAuthResponse response = OAuthResponse.builder() - .platformId("1") - .platformType(GITHUB) - .email("32183520@dankook.ac.kr") - .name("jusung-c") - .profileImageUrl("http://www.naver.com") - .build(); + OAuthResponse response = generateOauthResponse(); // when // when 사용시 Mockito 패키지 사용 @@ -73,7 +112,35 @@ void githubLoginSuccess() { } @Test - @DisplayName("깃허브 로그인에 실하면 OAuthException 예외가 발생한다.") + @DisplayName("카카오 로그인에 성공하면 OAuthResponse 객체를 반환한다.") + void kakaoLoginSuccess() { + // given + String code = "valid-code"; + String state = "valid-state"; + + OAuthResponse response = OAuthResponse.builder() + .platformId("1") + .platformType(KAKAO) + .email("이메일 없음") + .name("구영민") + .profileImageUrl("http://k.kakaocdn.net/dn/1G9kp/btsAot8liOn/8CWudi3uy07rvFNUkk3ER0/img_640x640.jpg") + .build(); + + // when + // when 사용시 Mockito 패키지 사용 + when(kakaoAdapter.getToken(any(String.class))).thenReturn("access-token"); + when(kakaoAdapter.getProfile(any(String.class))).thenReturn(response); + OAuthResponse profile = oAuthService.login(KAKAO, code, state); + + // then + assertThat(profile) + .extracting("platformId", "platformType", "email", "name", "profileImageUrl") + .contains("1", KAKAO, "이메일 없음", "구영민", "http://k.kakaocdn.net/dn/1G9kp/btsAot8liOn/8CWudi3uy07rvFNUkk3ER0/img_640x640.jpg"); + + + } + @Test + @DisplayName("깃허브 로그인에 실패하면 OAuthException 예외가 발생한다.") void githubLoginFail() { // given String code = "invalid-code"; @@ -83,6 +150,62 @@ void githubLoginFail() { when(githubAdapter.getToken(any(String.class))).thenThrow(OAuthException.class); assertThrows(OAuthException.class, () -> oAuthService.login(GITHUB, code, state)); + } + + @Test + @DisplayName("구글 로그인에 성공하면 OAuthResponse 객체를 반환한다.") + void googleLoginSuccess() { + // given + String code = "valid-code"; + String state = "valid-state"; + + OAuthResponse response = OAuthResponse.builder() + .platformId("102514823309503386675") + .platformType(GOOGLE) + .email("xw21yog@dankook.ac.kr") + .name("이정우") + .profileImageUrl("https://lh3.googleusercontent.com/a/ACg8ocLrP_GLo-fUjSmnUZedPZbbL7ifImYTnelh108XkgOx=s96-c") + .build(); + + // when + // when 사용시 Mockito 패키지 사용 + when(googleAdapter.getToken(any(String.class))).thenReturn("access-token"); + when(googleAdapter.getProfile(any(String.class))).thenReturn(response); + OAuthResponse profile = oAuthService.login(GOOGLE, code, state); + + // then + assertThat(profile) + .extracting("platformId", "platformType", "email", "name", "profileImageUrl") + .contains("102514823309503386675", GOOGLE, "xw21yog@dankook.ac.kr", "이정우", "https://lh3.googleusercontent.com/a/ACg8ocLrP_GLo-fUjSmnUZedPZbbL7ifImYTnelh108XkgOx=s96-c"); + + + } + + @Test + @DisplayName("구글 로그인에 실하면 OAuthException 예외가 발생한다.") + void googleLoginFail() { + // given + String code = "invalid-code"; + String state = "invalid-state"; + + // when + when(googleAdapter.getToken(any(String.class))).thenThrow(OAuthException.class); + assertThrows(OAuthException.class, + () -> oAuthService.login(GOOGLE, code, state)); + + } + + @Test + @DisplayName("카카오 로그인에 실패하면 OAuthException 예외가 발생한다.") + void kakaoLoginFail() { + // given + String code = "invalid-code"; + String state = "invalid-state"; + + // when + when(kakaoAdapter.getToken(any(String.class))).thenThrow(OAuthException.class); + assertThrows(OAuthException.class, + () -> oAuthService.login(KAKAO, code, state)); } } \ No newline at end of file diff --git a/backend/src/test/java/com/example/backend/auth/api/service/oauth/adapter/google/GoogleAdapterTest.java b/backend/src/test/java/com/example/backend/auth/api/service/oauth/adapter/google/GoogleAdapterTest.java new file mode 100644 index 000000000..2b6b075f4 --- /dev/null +++ b/backend/src/test/java/com/example/backend/auth/api/service/oauth/adapter/google/GoogleAdapterTest.java @@ -0,0 +1,122 @@ +package com.example.backend.auth.api.service.oauth.adapter.google; + + +import com.example.backend.auth.TestConfig; +import com.example.backend.auth.api.service.oauth.builder.google.GoogleURLBuilder; +import com.example.backend.auth.api.service.oauth.response.OAuthResponse; +import com.example.backend.common.exception.ExceptionMessage; +import com.example.backend.common.exception.oauth.OAuthException; + +import com.example.backend.external.clients.oauth.google.GoogleProfileClients; +import com.example.backend.external.clients.oauth.google.GoogleTokenClients; +import com.example.backend.external.clients.oauth.google.response.GoogleProfileResponse; +import com.example.backend.external.clients.oauth.google.response.GoogleTokenResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.net.URI; + +import static com.example.backend.domain.define.user.constant.UserPlatformType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class GoogleAdapterTest extends TestConfig { + + @Autowired + private GoogleAdapter googleAdapter; + + @Autowired + private GoogleURLBuilder googleURLBuilder; + + public static String expectedPlatformId = "102514823309503386675"; // google은 sub + public static String expectedProfileImageUrl = "https://lh3.googleusercontent.com/a/ACg8ocLrP_GLo-fUjSmnUZedPZbbL7ifImYTnelh108XkgOx=s96-c"; + public static String expectedName = "이정우"; + + @Test + @DisplayName("google 토큰 요청 API에 정상적인 요청을 보내면, access_token이 발행된다.") + void googleAdapterGetTokenSuccess() { + // given + GoogleAdapterTest.MockGoogleTokenClients mockGoogleTokenClients = new GoogleAdapterTest.MockGoogleTokenClients(); + GoogleAdapterTest.MockGoogleProfileClients mockGoogleProfileClients = new GoogleAdapterTest.MockGoogleProfileClients(); + GoogleAdapter googleAdapter = new GoogleAdapter(mockGoogleTokenClients, mockGoogleProfileClients); + + // when + String accessToken = googleAdapter.getToken("tokenUrl"); + + // then + //System.out.println("accessToken = " + accessToken); + assertThat(accessToken).isEqualTo("access-token"); + + } + + @Test + @DisplayName("google 토큰 요청 중 예외가 발생하면, OAUTH_INVALID_TOKEN_URL 에외가 발생한다.") + void googleAdapterGetTokenFail() { + // given + String tokenURL = googleURLBuilder.token("error-token", "state"); + + // when + OAuthException exception = assertThrows(OAuthException.class, + () -> googleAdapter.getToken(tokenURL)); + + // then + assertThat(exception.getMessage()).isEqualTo(ExceptionMessage.OAUTH_INVALID_TOKEN_URL.getText()); + + } + + @Test + @DisplayName("google 프로필 요청 API에 정상적인 요청을 보내면, 사용자 프로필이 반환된다.") + void googleAdapterGetProfileSuccess() { + // given + + GoogleAdapterTest.MockGoogleTokenClients mockGoogleTokenClients = new GoogleAdapterTest.MockGoogleTokenClients(); + GoogleAdapterTest.MockGoogleProfileClients mockGoogleProfileClients = new GoogleAdapterTest.MockGoogleProfileClients(); + GoogleAdapter googleAdapter = new GoogleAdapter(mockGoogleTokenClients, mockGoogleProfileClients); + + // when + OAuthResponse profile = googleAdapter.getProfile("access-token"); + + // then + assertAll( + () -> assertThat(profile.getPlatformId()).isEqualTo(expectedPlatformId), // google은 sub + () -> assertThat(profile.getProfileImageUrl()).isEqualTo(expectedProfileImageUrl), + () -> assertThat(profile.getName()).isEqualTo(expectedName), + () -> assertThat(profile.getPlatformType()).isEqualTo(GOOGLE) + ); + } + + @Test + @DisplayName("google 프로필 요청 중 예외가 발생하면, OAUTH_INVALID_ACCESS_TOKEN 예외가 발생한다.") + void googleAdapterGetProfileFail() { + + // when + OAuthException exception = assertThrows(OAuthException.class, + () -> googleAdapter.getProfile("error-token")); + + // then + assertThat(exception.getMessage()).isEqualTo(ExceptionMessage.OAUTH_INVALID_ACCESS_TOKEN.getText()); + + } + + + + static class MockGoogleTokenClients implements GoogleTokenClients { + + @Override + public GoogleTokenResponse getToken(URI uri) { + return new GoogleTokenResponse("access-token"); + } + } + + static class MockGoogleProfileClients implements GoogleProfileClients { + + @Override + public GoogleProfileResponse getProfile(String header) { + return new GoogleProfileResponse(GoogleAdapterTest.expectedPlatformId, + GoogleAdapterTest.expectedName, + GoogleAdapterTest.expectedProfileImageUrl); + } + } +} diff --git a/backend/src/test/java/com/example/backend/auth/api/service/oauth/adapter/kakao/KakaoAdapterTest.java b/backend/src/test/java/com/example/backend/auth/api/service/oauth/adapter/kakao/KakaoAdapterTest.java new file mode 100644 index 000000000..432cf8444 --- /dev/null +++ b/backend/src/test/java/com/example/backend/auth/api/service/oauth/adapter/kakao/KakaoAdapterTest.java @@ -0,0 +1,150 @@ +package com.example.backend.auth.api.service.oauth.adapter.kakao; + + +import com.example.backend.auth.TestConfig; +import com.example.backend.auth.api.service.oauth.builder.kakao.KakaoURLBuilder; + +import com.example.backend.auth.api.service.oauth.response.OAuthResponse; +import com.example.backend.common.exception.ExceptionMessage; +import com.example.backend.common.exception.oauth.OAuthException; +import com.example.backend.external.clients.oauth.kakao.KakaoProfileClients; +import com.example.backend.external.clients.oauth.kakao.KakaoTokenClients; +import com.example.backend.external.clients.oauth.kakao.response.KakaoProfileResponse; +import com.example.backend.external.clients.oauth.kakao.response.KakaoTokenResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.net.URI; + +import static com.example.backend.domain.define.user.constant.UserPlatformType.KAKAO; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class KakaoAdapterTest extends TestConfig { + @Autowired + private KakaoAdapter kakaoAdapter; + + @Autowired + private KakaoURLBuilder kakaoURLBuilder; + @Test + @DisplayName("kakao 토큰 요청 API에 정상적인 요청을 보내면, access_token이 발행된다.") + void kakaoAdapterGetTokenSuccess() { + // given + KakaoAdapterTest.MockKakaoTokenClients mockKakaoTokenClients = new KakaoAdapterTest.MockKakaoTokenClients(); + KakaoAdapterTest.MockKakaoProfileClients mockKakaoProfileClients = new KakaoAdapterTest.MockKakaoProfileClients(); + KakaoAdapter kakaoAdapter = new KakaoAdapter(mockKakaoTokenClients, mockKakaoProfileClients); + + // when + String accessToken = kakaoAdapter.getToken("tokenUrl"); + + // then + System.out.println("accessToken = " + accessToken); + assertThat(accessToken).isEqualTo("access-token"); + + } + + + @Test + @DisplayName("kakao 토큰 요청 중 예외가 발생하면, OAUTH_INVALID_TOKEN_URL 에외가 발생한다.") + void kakaoAdapterGetTokenFail() { + // given + String tokenURL = kakaoURLBuilder.token("error-token", "state"); + + // when + OAuthException exception = assertThrows(OAuthException.class, + () -> kakaoAdapter.getToken(tokenURL)); + + // then + assertThat(exception.getMessage()).isEqualTo(ExceptionMessage.OAUTH_INVALID_TOKEN_URL.getText()); + + } + + + + + @Test + @DisplayName("kakao 프로필 요청 API에 정상적인 요청을 보내면, 사용자 프로필이 반환된다.") + void kakaoAdapterGetProfileSuccess() { + // given + Long expetedId = 1L; + String expectedName = "구영민"; + String expectedNickName = "구영민"; + String expectedProfileImageUrl = "http://k.kakaocdn.net/dn/1G9kp/btsAot8liOn/8CWudi3uy07rvFNUkk3ER0/img_640x640.jpg"; + String expectedThumbnailImage = "http://k.kakaocdn.net/dn/1G9kp/btsAot8liOn/8CWudi3uy07rvFNUkk3ER0/img_110x110.jpg"; + + KakaoAdapterTest.MockKakaoTokenClients mockKakaoTokenClients = new KakaoAdapterTest.MockKakaoTokenClients(); + KakaoAdapterTest.MockKakaoProfileClients mockKakaoProfileClients = new KakaoAdapterTest.MockKakaoProfileClients(expetedId, + expectedName, + new KakaoProfileResponse.Properties(expectedNickName, expectedProfileImageUrl, expectedThumbnailImage)); + KakaoAdapter kakaoAdapter = new KakaoAdapter(mockKakaoTokenClients, mockKakaoProfileClients); + + // when + OAuthResponse profile = kakaoAdapter.getProfile("access-token"); + + // then + assertAll( + () -> assertThat(profile.getPlatformId()).isEqualTo(expetedId.toString()), + () -> assertThat(profile.getProfileImageUrl()).isEqualTo(expectedProfileImageUrl), + () -> assertThat(profile.getName()).isEqualTo(expectedName), + () -> assertThat(profile.getPlatformType()).isEqualTo(KAKAO) + ); + } + + + @Test // X + @DisplayName("kakao 프로필 요청 중 예외가 발생하면, OAUTH_INVALID_ACCESS_TOKEN 예외가 발생한다.") + void kakaoAdapterGetProfileFail() { + // when + OAuthException exception = assertThrows(OAuthException.class, + () -> kakaoAdapter.getProfile("error-token")); + + // then + assertThat(exception.getMessage()).isEqualTo(ExceptionMessage.OAUTH_INVALID_ACCESS_TOKEN.getText()); + + } + + + + + + static class MockKakaoTokenClients implements KakaoTokenClients { + + @Override + public KakaoTokenResponse getToken(URI uri) { + return new KakaoTokenResponse("access-token"); + } + } + static class MockKakaoProfileClients implements KakaoProfileClients { + private Long id; + private String name; + private KakaoProfileResponse.Properties properties; + MockKakaoProfileClients(Long id, String name, KakaoProfileResponse.Properties properties){ + this.id = id; + this.name = name; + this.properties = properties; + } + MockKakaoProfileClients(){}; + public static class Properties { + private String nickname; + private String profile_image; + private String thumbnail_image; + + public Properties(String nickname, String profile_image, String thumbnail_image) { + this.nickname = nickname; + this.profile_image = profile_image; + this.thumbnail_image = thumbnail_image; + } + } + @Override + public KakaoProfileResponse getProfile(String header) { + return new KakaoProfileResponse(id, + name, + new KakaoProfileResponse.Properties(properties.getNickname(), + properties.getProfile_image(), + properties.getThumbnail_image())) + ; + } + } +} diff --git a/backend/src/test/java/com/example/backend/auth/api/service/oauth/builder/google/GoogleURLBuilderTest.java b/backend/src/test/java/com/example/backend/auth/api/service/oauth/builder/google/GoogleURLBuilderTest.java new file mode 100644 index 000000000..0ca1ead67 --- /dev/null +++ b/backend/src/test/java/com/example/backend/auth/api/service/oauth/builder/google/GoogleURLBuilderTest.java @@ -0,0 +1,77 @@ +package com.example.backend.auth.api.service.oauth.builder.google; + +import com.example.backend.auth.TestConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GoogleURLBuilderTest extends TestConfig { + + @Autowired + private GoogleURLBuilder urlBuilder; + + @Value("${oauth2.client.google.client-id}") private String clientId; + @Value("${oauth2.client.google.client-secret}") private String clientSecret; + @Value("${oauth2.client.google.redirect-uri}") private String redirectUri; + + @Value("${oauth2.provider.google.authorization-uri}") String authorizationUri; + @Value("${oauth2.provider.google.token-uri}") private String tokenUri; + @Value("${oauth2.provider.google.profile-uri}") private String profileUri; + + @Test + @DisplayName("authorize(인가 코드 요청) URL을 성공적으로 생성한다.") + void authorizeURIBuildSuccess() { + // given + String state = "testState"; + + // when + String authorizeURL = urlBuilder.authorize(state); + + // then + System.out.println("authorize URL : " + authorizeURL); + assertThat(authorizeURL).isEqualTo(authorizationUri + + "?response_type=code" + + "&client_id=" + clientId + + "&redirect_uri=" + redirectUri + + "&state=" + state + + "&scope=email+profile"); // openid가아닌 email+profile 변경 + } + + @Test + @DisplayName("token(Access Token 요청) URL을 성공적으로 생성한다.") + void tokenURIBuildSuccess() { + // given + String code = "testCode"; + String state = "testState"; + + // when + String tokenURL = urlBuilder.token(code, state); + + // then + System.out.println("tokenURL : " + tokenURL); + assertThat(tokenURL).isEqualTo(tokenUri + + "?grant_type=authorization_code" + + "&client_id=" + clientId + + "&client_secret=" + clientSecret + + "&redirect_uri=" + redirectUri + + "&code=" + code); + } + + @Test + @DisplayName("profile(사용자 정보 요청) URL을 성공적으로 생성한다.") + void profileURIBuildSuccess() { + // given + + // when + String profileURL = urlBuilder.profile(); + + // then + System.out.println("profileURL : " + profileURL); + assertThat(profileURL).isEqualTo(profileUri); + + } + +} diff --git a/backend/src/test/java/com/example/backend/auth/api/service/oauth/builder/kakao/KakaoURLBuilderTest.java b/backend/src/test/java/com/example/backend/auth/api/service/oauth/builder/kakao/KakaoURLBuilderTest.java new file mode 100644 index 000000000..0b2009fd0 --- /dev/null +++ b/backend/src/test/java/com/example/backend/auth/api/service/oauth/builder/kakao/KakaoURLBuilderTest.java @@ -0,0 +1,76 @@ +package com.example.backend.auth.api.service.oauth.builder.kakao; + +import com.example.backend.auth.TestConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import static org.assertj.core.api.Assertions.assertThat; + +public class KakaoURLBuilderTest extends TestConfig { + @Autowired + private KakaoURLBuilder urlBuilder; + + @Value("${oauth2.client.kakao.client-id}") private String clientId; + @Value("${oauth2.client.kakao.client-secret}") private String clientSecret; + @Value("${oauth2.client.kakao.redirect-uri}") private String redirectUri; + + @Value("${oauth2.provider.kakao.authorization-uri}") String authorizationUri; + @Value("${oauth2.provider.kakao.token-uri}") private String tokenUri; + @Value("${oauth2.provider.kakao.profile-uri}") private String profileUri; + + @Test + @DisplayName("authorize(인가 코드 요청) URL을 성공적으로 생성한다.") + void authorizeURIBuildSuccess() { + // given + String state = "testState"; + + // when + String authorizeURL = urlBuilder.authorize(state); + + // then + System.out.println("authorize URL : " + authorizeURL); + assertThat(authorizeURL).isEqualTo(authorizationUri + + "?response_type=code" + + "&client_id=" + clientId + + "&redirect_uri=" + redirectUri + + "&state=" + state + + "&scope=openid"); + } + + + @Test + @DisplayName("token(Access Token 요청) URL을 성공적으로 생성한다.") + void tokenURIBuildSuccess() { + // given + String code = "testCode"; + String state = "testState"; + + // when + String tokenURL = urlBuilder.token(code, state); + + // then + System.out.println("tokenURL : " + tokenURL); + assertThat(tokenURL).isEqualTo(tokenUri + + "?grant_type=authorization_code" + + "&client_id=" + clientId + + "&client_secret=" + clientSecret + + "&redirect_uri=" + redirectUri + + "&code=" + code); + } + + @Test + @DisplayName("profile(사용자 정보 요청) URL을 성공적으로 생성한다.") + void profileURIBuildSuccess() { + // given + + // when + String profileURL = urlBuilder.profile(); + + // then + System.out.println("profileURL : " + profileURL); + assertThat(profileURL).isEqualTo(profileUri); + + } +} diff --git a/backend/src/test/java/com/example/backend/external/clients/oauth/kakao/KakaoClientTest.java b/backend/src/test/java/com/example/backend/external/clients/oauth/kakao/KakaoClientTest.java new file mode 100644 index 000000000..d0abdfc1a --- /dev/null +++ b/backend/src/test/java/com/example/backend/external/clients/oauth/kakao/KakaoClientTest.java @@ -0,0 +1,55 @@ +package com.example.backend.external.clients.oauth.kakao; + +import com.example.backend.auth.TestConfig; +import com.example.backend.external.clients.oauth.kakao.response.KakaoProfileResponse; +import com.example.backend.external.clients.oauth.kakao.response.KakaoTokenResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.net.URI; + +import static org.junit.jupiter.api.Assertions.assertNull; + +public class KakaoClientTest extends TestConfig { + @Autowired + private KakaoTokenClients kakaoTokenClients; + + @Autowired + private KakaoProfileClients kakaoProfileClients; + + @Test + @DisplayName("인가 code를 URL에 담아 요청해 access_token을 성공적으로 받환받는다.") + void kakaoTokenRequestTest() { + // given + //String uri = "https://kauth.kakao.com/oauth/token?grant_type=authorization_code&client_id=1d8513aae332ebe7462f429d67f3cacc&client_secret=T2dW4O4bFTPYz7PKflnIlqqfYXNbr2U6&redirect_uri=http://localhost:8080/auth/KAKAO/login&code="; + + + // when + //KakaoTokenResponse token = kakaoTokenClients.getToken(URI.create(uri)); + //System.out.println("token = " + token.getAccess_token()); + + // then + // 현재는 동적인 테스트 불가하므로 null. OauthService 구현 후 동적으로 테스트할 예정 + //assertNull(token.getAccess_token()); + } + + @Test + @DisplayName("Access Token을 URL에 담아 요청해 사용자 정보를 성공적으로 받환받는다.") + void githubProfileRequestTest() { + // given + String accessToken = ""; + + /*KakaoProfileResponse profile = kakaoProfileClients.getProfile("Bearer " + accessToken); + + System.out.println("login = " + profile.getLogin()); + System.out.println("email = " + profile.getEmail()); + System.out.println("name = " + profile.getName()); + + assertAll( + () -> assertThat(profile.getLogin()).isEqualTo("jusung-c"), + () -> assertThat(profile.getEmail()).isEqualTo("anaooauc1236@naver.com"), + () -> assertThat(profile.getName()).isEqualTo("이주성") + );*/ + } +}