diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 44d549ad..cb374a49 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -31,6 +31,12 @@ jobs: echo "${{ secrets.APPLICATION_DEV }}" >> ./application-dev.yml cat ./application-dev.yml + - name: 'Get key from Github Secrets' + run: | + pwd + mkdir -p ./operation-api/src/main/resources/static + echo "${{ secrets.APPLE_KEY }}" | base64 --decode > ./operation-api/src/main/resources/static/${{ secrets.APPLE_KEY_NAME }} + - name: Build with Gradle run: ./gradlew build shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32b14339..2a6f1089 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,12 @@ jobs: echo "${{ secrets.APPLICATION_DEV }}" >> ./application-dev.yml cat ./application-dev.yml + - name: 'Get key from Github Secrets' + run: | + pwd + mkdir -p ./operation-api/src/main/resources/static + echo "${{ secrets.APPLE_KEY }}" | base64 --decode > ./operation-api/src/main/resources/static/${{ secrets.APPLE_KEY_NAME }} + - name: Build with Gradle run: ./gradlew build shell: bash \ No newline at end of file diff --git a/operation-api/build.gradle b/operation-api/build.gradle index 2d20d26b..f01b8b8c 100644 --- a/operation-api/build.gradle +++ b/operation-api/build.gradle @@ -21,4 +21,8 @@ dependencies { // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + // jwt builder library + implementation 'io.jsonwebtoken:jjwt-api:0.11.2' + runtimeOnly "io.jsonwebtoken:jjwt-impl:0.11.2" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.11.2" } \ No newline at end of file diff --git a/operation-api/src/main/java/org/sopt/makers/operation/auth/api/AuthApi.java b/operation-api/src/main/java/org/sopt/makers/operation/auth/api/AuthApi.java new file mode 100644 index 00000000..ad898260 --- /dev/null +++ b/operation-api/src/main/java/org/sopt/makers/operation/auth/api/AuthApi.java @@ -0,0 +1,48 @@ +package org.sopt.makers.operation.auth.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import org.sopt.makers.operation.dto.BaseResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; + +public interface AuthApi { + + @Operation( + security = @SecurityRequirement(name = "Authorization"), + summary = "플랫폼 인가코드 반환 API", + responses = { + @ApiResponse( + responseCode = "200", + description = "플랫폼 인가코드 반환 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1. 쿼리 파라미터 중 데이터가 들어오지 않았습니다.\n + 2. 유효하지 않은 social type 입니다.\n + 3. 유효하지 않은 id token 입니다.\n + 4. 유효하지 않은 social code 입니다. + """ + ), + @ApiResponse( + responseCode = "404", + description = """ + 1. 등록되지 않은 팀입니다.\n + 2. 등록된 소셜 정보가 없습니다. + """ + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류" + ) + } + ) + ResponseEntity> authorize( + @RequestParam String type, + @RequestParam String code, + @RequestParam String clientId, + @RequestParam String redirectUri + ); +} diff --git a/operation-api/src/main/java/org/sopt/makers/operation/auth/api/AuthApiController.java b/operation-api/src/main/java/org/sopt/makers/operation/auth/api/AuthApiController.java new file mode 100644 index 00000000..740b130b --- /dev/null +++ b/operation-api/src/main/java/org/sopt/makers/operation/auth/api/AuthApiController.java @@ -0,0 +1,60 @@ +package org.sopt.makers.operation.auth.api; + +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.sopt.makers.operation.auth.dto.response.AuthorizationCodeResponse; +import org.sopt.makers.operation.auth.service.AuthService; +import org.sopt.makers.operation.dto.BaseResponse; +import org.sopt.makers.operation.exception.AuthException; +import org.sopt.makers.operation.user.domain.SocialType; +import org.sopt.makers.operation.util.ApiResponseUtil; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.concurrent.ConcurrentHashMap; + +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.INVALID_SOCIAL_TYPE; +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.NOT_FOUNT_REGISTERED_TEAM; +import static org.sopt.makers.operation.code.success.auth.AuthSuccessCode.SUCCESS_GET_AUTHORIZATION_CODE; + +@RestController +@RequiredArgsConstructor +public class AuthApiController implements AuthApi { + + private final ConcurrentHashMap tempPlatformCode = new ConcurrentHashMap<>(); + private final AuthService authService; + + @Override + @GetMapping("/api/v1/authorize") + public ResponseEntity> authorize( + @RequestParam String type, + @RequestParam String code, + @RequestParam String clientId, + @RequestParam String redirectUri + ) { + if (!authService.checkRegisteredTeamOAuthInfo(clientId, redirectUri)) { + throw new AuthException(NOT_FOUNT_REGISTERED_TEAM); + } + if (!SocialType.isContains(type)) { + throw new AuthException(INVALID_SOCIAL_TYPE); + } + + val userId = findUserIdBySocialTypeAndCode(type, code); + val platformCode = generatePlatformCode(clientId, redirectUri, userId); + return ApiResponseUtil.success(SUCCESS_GET_AUTHORIZATION_CODE, new AuthorizationCodeResponse(platformCode)); + } + + private Long findUserIdBySocialTypeAndCode(String type, String code) { + val socialType = SocialType.valueOf(type); + val userSocialId = authService.getSocialUserInfo(socialType, code); + return authService.getUserId(socialType, userSocialId); + } + + private String generatePlatformCode(String clientId, String redirectUri, Long userId) { + val platformCode = authService.generatePlatformCode(clientId, redirectUri, userId); + tempPlatformCode.putIfAbsent(platformCode, platformCode); + return platformCode; + } +} diff --git a/operation-api/src/main/java/org/sopt/makers/operation/auth/dto/response/AuthorizationCodeResponse.java b/operation-api/src/main/java/org/sopt/makers/operation/auth/dto/response/AuthorizationCodeResponse.java new file mode 100644 index 00000000..595c45fb --- /dev/null +++ b/operation-api/src/main/java/org/sopt/makers/operation/auth/dto/response/AuthorizationCodeResponse.java @@ -0,0 +1,4 @@ +package org.sopt.makers.operation.auth.dto.response; + +public record AuthorizationCodeResponse(String platformCode) { +} diff --git a/operation-api/src/main/java/org/sopt/makers/operation/auth/service/AuthService.java b/operation-api/src/main/java/org/sopt/makers/operation/auth/service/AuthService.java new file mode 100644 index 00000000..aca12053 --- /dev/null +++ b/operation-api/src/main/java/org/sopt/makers/operation/auth/service/AuthService.java @@ -0,0 +1,13 @@ +package org.sopt.makers.operation.auth.service; + +import org.sopt.makers.operation.user.domain.SocialType; + +public interface AuthService { + boolean checkRegisteredTeamOAuthInfo(String clientId, String redirectUri); + + String getSocialUserInfo(SocialType type, String code); + + Long getUserId(SocialType socialType, String userSocialId); + + String generatePlatformCode(String clientId, String redirectUri, Long userId); +} diff --git a/operation-api/src/main/java/org/sopt/makers/operation/auth/service/AuthServiceImpl.java b/operation-api/src/main/java/org/sopt/makers/operation/auth/service/AuthServiceImpl.java new file mode 100644 index 00000000..486e38aa --- /dev/null +++ b/operation-api/src/main/java/org/sopt/makers/operation/auth/service/AuthServiceImpl.java @@ -0,0 +1,72 @@ +package org.sopt.makers.operation.auth.service; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.xml.bind.DatatypeConverter; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.sopt.makers.operation.auth.repository.TeamOAuthInfoRepository; +import org.sopt.makers.operation.client.social.SocialLoginManager; +import org.sopt.makers.operation.config.ValueConfig; +import org.sopt.makers.operation.exception.AuthException; +import org.sopt.makers.operation.user.domain.SocialType; +import org.sopt.makers.operation.user.repository.identityinfo.UserIdentityInfoRepository; +import org.springframework.stereotype.Service; + +import javax.crypto.spec.SecretKeySpec; +import java.time.ZoneId; +import java.util.Date; + +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.INVALID_SOCIAL_CODE; +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.NOT_FOUND_USER_SOCIAL_IDENTITY_INFO; + +@Service +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final SocialLoginManager socialLoginManager; + private final TeamOAuthInfoRepository teamOAuthInfoRepository; + private final UserIdentityInfoRepository userIdentityInfoRepository; + private final ValueConfig valueConfig; + + @Override + public boolean checkRegisteredTeamOAuthInfo(String clientId, String redirectUri) { + return teamOAuthInfoRepository.existsByClientIdAndRedirectUri(clientId, redirectUri); + } + + @Override + public String getSocialUserInfo(SocialType type, String code) { + val idToken = socialLoginManager.getIdTokenByCode(type, code); + if (idToken == null) { + throw new AuthException(INVALID_SOCIAL_CODE); + } + return socialLoginManager.getUserInfo(idToken); + } + + @Override + public Long getUserId(SocialType socialType, String userSocialId) { + val userIdentityInfo = userIdentityInfoRepository.findBySocialTypeAndSocialId(socialType, userSocialId) + .orElseThrow(() -> new AuthException(NOT_FOUND_USER_SOCIAL_IDENTITY_INFO)); + return userIdentityInfo.getUserId(); + } + + @Override + public String generatePlatformCode(String clientId, String redirectUri, Long userId) { + val platformCodeSecretKey = valueConfig.getPlatformCodeSecretKey(); + + val signatureAlgorithm = SignatureAlgorithm.HS256; + val secretKeyBytes = DatatypeConverter.parseBase64Binary(platformCodeSecretKey); + val signingKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName()); + val exp = new Date().toInstant().atZone(KST) + .toLocalDateTime().plusMinutes(5).atZone(KST).toInstant(); + return Jwts.builder() + .setIssuer(clientId) + .setAudience(redirectUri) + .setSubject(Long.toString(userId)) + .setExpiration(Date.from(exp)) + .signWith(signingKey, signatureAlgorithm) + .compact(); + } +} diff --git a/operation-api/src/main/java/org/sopt/makers/operation/common/handler/ErrorHandler.java b/operation-api/src/main/java/org/sopt/makers/operation/common/handler/ErrorHandler.java index 5a4093f2..630b59ec 100644 --- a/operation-api/src/main/java/org/sopt/makers/operation/common/handler/ErrorHandler.java +++ b/operation-api/src/main/java/org/sopt/makers/operation/common/handler/ErrorHandler.java @@ -1,6 +1,7 @@ package org.sopt.makers.operation.common.handler; import org.sopt.makers.operation.dto.BaseResponse; +import org.sopt.makers.operation.exception.AuthException; import org.sopt.makers.operation.util.ApiResponseUtil; import org.sopt.makers.operation.exception.AdminFailureException; import org.sopt.makers.operation.exception.AlarmException; @@ -74,4 +75,10 @@ public ResponseEntity> attendanceException(AttendanceException e return ApiResponseUtil.failure(ex.getFailureCode()); } + @ExceptionHandler(AuthException.class) + public ResponseEntity> authException(AuthException ex) { + log.error(ex.getMessage()); + return ApiResponseUtil.failure(ex.getFailureCode()); + } + } diff --git a/operation-api/src/test/java/org/sopt/makers/operation/auth/api/AuthApiControllerTest.java b/operation-api/src/test/java/org/sopt/makers/operation/auth/api/AuthApiControllerTest.java new file mode 100644 index 00000000..1706a681 --- /dev/null +++ b/operation-api/src/test/java/org/sopt/makers/operation/auth/api/AuthApiControllerTest.java @@ -0,0 +1,130 @@ +package org.sopt.makers.operation.auth.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.sopt.makers.operation.auth.service.AuthService; +import org.sopt.makers.operation.common.handler.ErrorHandler; +import org.sopt.makers.operation.user.domain.SocialType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ContextConfiguration(classes = AuthApiController.class) +@WebMvcTest(controllers = {AuthApiController.class}, excludeAutoConfiguration = {SecurityAutoConfiguration.class}) +@Import({AuthApiController.class, ErrorHandler.class}) +class AuthApiControllerTest { + @MockBean + AuthService authService; + @Autowired + MockMvc mockMvc; + @Autowired + ObjectMapper objectMapper; + final String uri = "/api/v1/authorize"; + + @Nested + @DisplayName("API 통신 성공 테스트") + class SuccessTest { + @DisplayName("유효한 type, code, clientId, redirectUri 값이 들어왔을 때, 플랫폼 인가코드를 반환한다.") + @ParameterizedTest + @CsvSource({ + "APPLE,code,clientId,redirectUri" + }) + void successTest(String type, String code, String clientId, String redirectUri) throws Exception { + // given + given(authService.checkRegisteredTeamOAuthInfo(clientId, redirectUri)).willReturn(true); + val socialType = SocialType.valueOf(type); + given(authService.getSocialUserInfo(socialType, code)).willReturn("123"); + given(authService.getUserId(socialType, "123")).willReturn(1L); + given(authService.generatePlatformCode(clientId, redirectUri, 1L)).willReturn("Platform Code"); + + // when, then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .param("type", type) + .param("code", code) + .param("clientId", clientId) + .param("redirectUri", redirectUri)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("플랫폼 인가코드 발급 성공")); + } + } + + @Nested + @DisplayName("쿼리 파라미터 유효성 검사 테스트") + class QueryParameterValidateTest { + + @DisplayName("type, code, clientId, redirectUri 중 하나라도 null 이 들어오면 400을 반환한다.") + @ParameterizedTest + @CsvSource({ + ",code,clientId,redirectUri", + "type,,clientId,redirectUri", + "type,code,,redirectUri", + "type,code,clientId," + }) + void validateTest(String type, String code, String clientId, String redirectUri) throws Exception { + // when, then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .param("type", type) + .param("code", code) + .param("clientId", clientId) + .param("redirectUri", redirectUri)) + .andExpect(status().isBadRequest()); + } + + @DisplayName("등록되지 않은 clientId, redirectUri 라면 404를 반환한다.") + @ParameterizedTest + @CsvSource({ + "type,code,clientId,redirectUri" + }) + void validateTest2(String type, String code, String clientId, String redirectUri) throws Exception { + // given + given(authService.checkRegisteredTeamOAuthInfo(clientId, redirectUri)).willReturn(false); + + // when, then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .param("type", type) + .param("code", code) + .param("clientId", clientId) + .param("redirectUri", redirectUri)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("등록되지 않은 팀입니다.")); + ; + } + + @DisplayName("등록되지 않은 social type 이라면 400을 반환한다.") + @ParameterizedTest + @CsvSource({ + "KAKAO,code,clientId,redirectUri" + }) + void validateTest3(String type, String code, String clientId, String redirectUri) throws Exception { + // given + given(authService.checkRegisteredTeamOAuthInfo(clientId, redirectUri)).willReturn(true); + + // when, then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .param("type", type) + .param("code", code) + .param("clientId", clientId) + .param("redirectUri", redirectUri)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("유효하지 않은 social type 입니다.")); + } + } +} \ No newline at end of file diff --git a/operation-api/src/test/java/org/sopt/makers/operation/auth/service/AuthServiceTest.java b/operation-api/src/test/java/org/sopt/makers/operation/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..66e7292f --- /dev/null +++ b/operation-api/src/test/java/org/sopt/makers/operation/auth/service/AuthServiceTest.java @@ -0,0 +1,114 @@ +package org.sopt.makers.operation.auth.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.SignatureException; +import jakarta.xml.bind.DatatypeConverter; +import lombok.val; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.makers.operation.auth.repository.TeamOAuthInfoRepository; +import org.sopt.makers.operation.client.social.SocialLoginManager; +import org.sopt.makers.operation.config.ValueConfig; +import org.sopt.makers.operation.exception.AuthException; +import org.sopt.makers.operation.user.domain.SocialType; +import org.sopt.makers.operation.user.repository.identityinfo.UserIdentityInfoRepository; + +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + @Mock + SocialLoginManager socialLoginManager; + @Mock + TeamOAuthInfoRepository teamOAuthInfoRepository; + @Mock + UserIdentityInfoRepository userIdentityInfoRepository; + @Mock + ValueConfig valueConfig; + + AuthService authService; + + @BeforeEach + void setUp() { + authService = new AuthServiceImpl(socialLoginManager, teamOAuthInfoRepository, userIdentityInfoRepository, valueConfig); + } + + @Nested + @DisplayName("getSocialUserInfo 메서드 테스트") + class GetSocialUserInfoMethodTest { + @DisplayName("socialLoginManager 으로부터 id token 값을 null 값을 받았다면 AuthException 을 반환한다.") + @Test + void test() { + // given + val socialType = SocialType.APPLE; + val code = "social code"; + given(socialLoginManager.getIdTokenByCode(socialType, code)).willReturn(null); + + // when, then + assertThatThrownBy(() -> authService.getSocialUserInfo(socialType, code)) + .isInstanceOf(AuthException.class) + .hasMessage("[AuthException] : 유효하지 않은 social code 입니다."); + } + } + + @Nested + @DisplayName("getUserId 메서드 테스트") + class GetUserIdMethodTest { + @DisplayName("사전에 등록되지 않은 social type, social id 가 들어왔을 때, AuthException 을 반환한다.") + @Test + void test() { + // given + val socialType = SocialType.APPLE; + val userSocialId = "user social id"; + given(userIdentityInfoRepository.findBySocialTypeAndSocialId(socialType, userSocialId)).willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> authService.getUserId(socialType, userSocialId)) + .isInstanceOf(AuthException.class) + .hasMessage("[AuthException] : 등록된 소셜 정보가 없습니다."); + } + } + + @Nested + @DisplayName("generatePlatformCode 메서드 테스트") + class GeneratePlatformCodeMethodTest { + final String platformSecretKey = "123456789123456789123456789123456789123456789123456789"; + + @DisplayName("iss:clientId, aud:redirectUri , sub:userId 인 jwt 토큰을 발급한다.") + @Test + void test() { + // given + val clientId = "clientId"; + val redirectUri = "redirectUri"; + val userId = 1L; + given(valueConfig.getPlatformCodeSecretKey()).willReturn(platformSecretKey); + + // when + String platformCode = authService.generatePlatformCode(clientId, redirectUri, userId); + Claims claims = getClaimsFromToken(platformCode); + + // then + assertThat(claims.getIssuer()).isEqualTo("clientId"); + assertThat(claims.getAudience()).isEqualTo("redirectUri"); + assertThat(claims.getSubject()).isEqualTo("1"); + } + + private Claims getClaimsFromToken(String token) throws SignatureException { + return Jwts.parserBuilder() + .setSigningKey(DatatypeConverter.parseBase64Binary(platformSecretKey)) + .build() + .parseClaimsJws(token) + .getBody(); + } + } +} \ No newline at end of file diff --git a/operation-common/src/main/java/org/sopt/makers/operation/code/failure/auth/AuthFailureCode.java b/operation-common/src/main/java/org/sopt/makers/operation/code/failure/auth/AuthFailureCode.java new file mode 100644 index 00000000..b519a879 --- /dev/null +++ b/operation-common/src/main/java/org/sopt/makers/operation/code/failure/auth/AuthFailureCode.java @@ -0,0 +1,27 @@ +package org.sopt.makers.operation.code.failure.auth; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.sopt.makers.operation.code.failure.FailureCode; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Getter +@RequiredArgsConstructor +public enum AuthFailureCode implements FailureCode { + // 400 + NOT_NULL_PARAMS(BAD_REQUEST, "쿼리 파라미터 중 데이터가 들어오지 않았습니다."), + INVALID_SOCIAL_TYPE(BAD_REQUEST, "유효하지 않은 social type 입니다."), + INVALID_ID_TOKEN(BAD_REQUEST, "유효하지 않은 id token 입니다."), + INVALID_SOCIAL_CODE(BAD_REQUEST, "유효하지 않은 social code 입니다."), + FAILURE_READ_PRIVATE_KEY(BAD_REQUEST, "Private key 읽기 실패"), + // 404 + NOT_FOUNT_REGISTERED_TEAM(NOT_FOUND, "등록되지 않은 팀입니다."), + NOT_FOUND_USER_SOCIAL_IDENTITY_INFO(NOT_FOUND, "등록된 소셜 정보가 없습니다."), + ; + + private final HttpStatus status; + private final String message; +} diff --git a/operation-common/src/main/java/org/sopt/makers/operation/code/success/auth/AuthSuccessCode.java b/operation-common/src/main/java/org/sopt/makers/operation/code/success/auth/AuthSuccessCode.java new file mode 100644 index 00000000..d86b1081 --- /dev/null +++ b/operation-common/src/main/java/org/sopt/makers/operation/code/success/auth/AuthSuccessCode.java @@ -0,0 +1,16 @@ +package org.sopt.makers.operation.code.success.auth; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.sopt.makers.operation.code.success.SuccessCode; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.OK; + +@Getter +@RequiredArgsConstructor +public enum AuthSuccessCode implements SuccessCode { + SUCCESS_GET_AUTHORIZATION_CODE(OK, "플랫폼 인가코드 발급 성공"); + private final HttpStatus status; + private final String message; +} diff --git a/operation-common/src/main/java/org/sopt/makers/operation/config/ValueConfig.java b/operation-common/src/main/java/org/sopt/makers/operation/config/ValueConfig.java index cdeaa8a7..ab98dcb7 100644 --- a/operation-common/src/main/java/org/sopt/makers/operation/config/ValueConfig.java +++ b/operation-common/src/main/java/org/sopt/makers/operation/config/ValueConfig.java @@ -36,6 +36,24 @@ public class ValueConfig { private String playGroundURI; @Value("${sopt.makers.playground.token}") private String playGroundToken; + @Value("${oauth.apple.key.id}") + private String appleKeyId; + @Value("${oauth.apple.key.path}") + private String appleKeyPath; + @Value("${oauth.apple.team.id}") + private String appleTeamId; + @Value("${oauth.apple.aud}") + private String appleAud; + @Value("${oauth.apple.sub}") + private String appleSub; + @Value("${oauth.google.client.id}") + private String googleClientId; + @Value("${oauth.google.client.secret}") + private String googleClientSecret; + @Value("${oauth.google.redirect.url}") + private String googleRedirectUrl; + @Value("${spring.jwt.secretKey.platform_code}") + private String platformCodeSecretKey; private final int SUB_LECTURE_MAX_ROUND = 2; private final int MAX_LECTURE_COUNT = 2; diff --git a/operation-common/src/main/java/org/sopt/makers/operation/exception/AuthException.java b/operation-common/src/main/java/org/sopt/makers/operation/exception/AuthException.java new file mode 100644 index 00000000..a391e5ac --- /dev/null +++ b/operation-common/src/main/java/org/sopt/makers/operation/exception/AuthException.java @@ -0,0 +1,14 @@ +package org.sopt.makers.operation.exception; + +import lombok.Getter; +import org.sopt.makers.operation.code.failure.FailureCode; + +@Getter +public class AuthException extends RuntimeException{ + private final FailureCode failureCode; + + public AuthException(FailureCode failureCode) { + super("[AuthException] : " + failureCode.getMessage()); + this.failureCode = failureCode; + } +} diff --git a/operation-domain/src/main/java/org/sopt/makers/operation/auth/domain/Team.java b/operation-domain/src/main/java/org/sopt/makers/operation/auth/domain/Team.java new file mode 100644 index 00000000..bd95112c --- /dev/null +++ b/operation-domain/src/main/java/org/sopt/makers/operation/auth/domain/Team.java @@ -0,0 +1,5 @@ +package org.sopt.makers.operation.auth.domain; + +public enum Team { + PLAYGROUND, CREW, APP +} diff --git a/operation-domain/src/main/java/org/sopt/makers/operation/auth/domain/TeamOAuthInfo.java b/operation-domain/src/main/java/org/sopt/makers/operation/auth/domain/TeamOAuthInfo.java new file mode 100644 index 00000000..fd8de518 --- /dev/null +++ b/operation-domain/src/main/java/org/sopt/makers/operation/auth/domain/TeamOAuthInfo.java @@ -0,0 +1,28 @@ +package org.sopt.makers.operation.auth.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +import static jakarta.persistence.GenerationType.IDENTITY; + +@Entity +public class TeamOAuthInfo { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(name = "client_id", nullable = false) + private String clientId; + + @Column(name = "redirect_uri", nullable = false) + private String redirectUri; + + @Column(name = "team", nullable = false) + @Enumerated(EnumType.STRING) + private Team team; +} diff --git a/operation-domain/src/main/java/org/sopt/makers/operation/auth/repository/TeamOAuthInfoRepository.java b/operation-domain/src/main/java/org/sopt/makers/operation/auth/repository/TeamOAuthInfoRepository.java new file mode 100644 index 00000000..586a652a --- /dev/null +++ b/operation-domain/src/main/java/org/sopt/makers/operation/auth/repository/TeamOAuthInfoRepository.java @@ -0,0 +1,8 @@ +package org.sopt.makers.operation.auth.repository; + +import org.sopt.makers.operation.auth.domain.TeamOAuthInfo; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TeamOAuthInfoRepository extends JpaRepository { + boolean existsByClientIdAndRedirectUri(String clientId, String redirectUri); +} diff --git a/operation-domain/src/main/java/org/sopt/makers/operation/user/domain/SocialType.java b/operation-domain/src/main/java/org/sopt/makers/operation/user/domain/SocialType.java index b3274ddf..bfca14f9 100644 --- a/operation-domain/src/main/java/org/sopt/makers/operation/user/domain/SocialType.java +++ b/operation-domain/src/main/java/org/sopt/makers/operation/user/domain/SocialType.java @@ -1,6 +1,14 @@ package org.sopt.makers.operation.user.domain; +import java.util.Arrays; + public enum SocialType { GOOGLE, - APPLE, + APPLE; + + + public static boolean isContains(String type) { + return Arrays.stream(SocialType.values()) + .anyMatch(socialType -> socialType.name().equals(type)); + } } diff --git a/operation-domain/src/main/java/org/sopt/makers/operation/user/repository/identityinfo/UserIdentityInfoRepository.java b/operation-domain/src/main/java/org/sopt/makers/operation/user/repository/identityinfo/UserIdentityInfoRepository.java new file mode 100644 index 00000000..1f513fe7 --- /dev/null +++ b/operation-domain/src/main/java/org/sopt/makers/operation/user/repository/identityinfo/UserIdentityInfoRepository.java @@ -0,0 +1,11 @@ +package org.sopt.makers.operation.user.repository.identityinfo; + +import org.sopt.makers.operation.user.domain.SocialType; +import org.sopt.makers.operation.user.domain.UserIdentityInfo; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserIdentityInfoRepository extends JpaRepository { + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); +} diff --git a/operation-external/build.gradle b/operation-external/build.gradle index 4c29cb51..2f9c2dc4 100644 --- a/operation-external/build.gradle +++ b/operation-external/build.gradle @@ -11,6 +11,16 @@ dependencies { implementation project(path: ':operation-domain') implementation 'org.springframework.boot:spring-boot-starter-web' + + implementation 'org.bouncycastle:bcprov-jdk18on:1.75' + implementation 'org.bouncycastle:bcpkix-jdk18on:1.75' + + // jwt builder library + implementation 'io.jsonwebtoken:jjwt-api:0.11.2' + runtimeOnly "io.jsonwebtoken:jjwt-impl:0.11.2" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.11.2" + // jwt payload read library + implementation "com.nimbusds:nimbus-jose-jwt:7.8.1" } test { diff --git a/operation-external/src/main/java/org/sopt/makers/operation/client/social/AppleSocialLogin.java b/operation-external/src/main/java/org/sopt/makers/operation/client/social/AppleSocialLogin.java new file mode 100644 index 00000000..1d2b5a5a --- /dev/null +++ b/operation-external/src/main/java/org/sopt/makers/operation/client/social/AppleSocialLogin.java @@ -0,0 +1,96 @@ +package org.sopt.makers.operation.client.social; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.sopt.makers.operation.client.social.dto.IdTokenResponse; +import org.sopt.makers.operation.config.ValueConfig; +import org.sopt.makers.operation.exception.AuthException; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.util.Collections; +import java.util.Date; +import java.util.Optional; + +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.FAILURE_READ_PRIVATE_KEY; + +@Component +@RequiredArgsConstructor +public class AppleSocialLogin { + // 1시간 + private static final int EXPIRATION_TIME_IN_MILLISECONDS = 3600 * 1000; + private static final String GRANT_TYPE = "authorization_code"; + private static final String HOST = "https://appleid.apple.com/auth/token"; + + private final RestTemplate restTemplate; + private final ValueConfig valueConfig; + + public IdTokenResponse getIdTokenByCode(String code) { + val tokenRequest = new LinkedMultiValueMap<>(); + val clientId = valueConfig.getAppleSub(); + val clientSecret = createClientSecret(); + + tokenRequest.add("client_id", clientId); + tokenRequest.add("client_secret", clientSecret); + tokenRequest.add("code", code); + tokenRequest.add("grant_type", GRANT_TYPE); + + val headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + val entity = new HttpEntity<>(tokenRequest, headers); + + return restTemplate.postForObject(HOST, entity, IdTokenResponse.class); + } + + private String createClientSecret() { + val now = new Date(); + val privateKey = getPrivateKey() + .orElseThrow(() -> new AuthException(FAILURE_READ_PRIVATE_KEY)); + val kid = valueConfig.getAppleKeyId(); + val issuer = valueConfig.getAppleTeamId(); + val aud = valueConfig.getAppleAud(); + val sub = valueConfig.getAppleSub(); + + return Jwts.builder() + .setHeaderParam("kid", kid) + .setHeaderParam("alg", "ES256") + .setIssuedAt(now) + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME_IN_MILLISECONDS)) + .setIssuer(issuer) + .setAudience(aud) + .setSubject(sub) + .signWith(privateKey, SignatureAlgorithm.ES256) + .compact(); + } + + private Optional getPrivateKey() { + val appleKeyPath = valueConfig.getAppleKeyPath(); + try { + val resource = new ClassPathResource(appleKeyPath); + val privateKey = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + val pemReader = new StringReader(privateKey); + val pemParser = new PEMParser(pemReader); + val converter = new JcaPEMKeyConverter(); + val object = (PrivateKeyInfo) pemParser.readObject(); + return Optional.of(converter.getPrivateKey(object)); + } catch (IOException e) { + return Optional.empty(); + } + } + +} diff --git a/operation-external/src/main/java/org/sopt/makers/operation/client/social/GoogleSocialLogin.java b/operation-external/src/main/java/org/sopt/makers/operation/client/social/GoogleSocialLogin.java new file mode 100644 index 00000000..c6860255 --- /dev/null +++ b/operation-external/src/main/java/org/sopt/makers/operation/client/social/GoogleSocialLogin.java @@ -0,0 +1,44 @@ +package org.sopt.makers.operation.client.social; + +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.sopt.makers.operation.client.social.dto.IdTokenResponse; +import org.sopt.makers.operation.config.ValueConfig; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.Collections; + +@Component +@RequiredArgsConstructor +public class GoogleSocialLogin { + private static final String GRANT_TYPE = "authorization_code"; + private static final String HOST = "https://oauth2.googleapis.com/token"; + + private final RestTemplate restTemplate; + private final ValueConfig valueConfig; + + public IdTokenResponse getIdTokenByCode(String code) { + val params = new LinkedMultiValueMap<>(); + val clientId = valueConfig.getGoogleClientId(); + val clientSecret = valueConfig.getGoogleClientSecret(); + val redirectUri = valueConfig.getGoogleRedirectUrl(); + + params.add("client_id", clientId); + params.add("client_secret", clientSecret); + params.add("code", code); + params.add("grant_type", GRANT_TYPE); + params.add("redirect_uri", redirectUri); + + val headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + val entity = new HttpEntity<>(params, headers); + + return restTemplate.postForObject(HOST, entity, IdTokenResponse.class); + } +} diff --git a/operation-external/src/main/java/org/sopt/makers/operation/client/social/SocialLoginManager.java b/operation-external/src/main/java/org/sopt/makers/operation/client/social/SocialLoginManager.java new file mode 100644 index 00000000..cdd296fd --- /dev/null +++ b/operation-external/src/main/java/org/sopt/makers/operation/client/social/SocialLoginManager.java @@ -0,0 +1,39 @@ +package org.sopt.makers.operation.client.social; + +import com.nimbusds.jwt.SignedJWT; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.sopt.makers.operation.client.social.dto.IdTokenResponse; +import org.sopt.makers.operation.exception.AuthException; +import org.sopt.makers.operation.user.domain.SocialType; +import org.springframework.stereotype.Component; + +import java.text.ParseException; + +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.INVALID_ID_TOKEN; + +@Component +@RequiredArgsConstructor +public class SocialLoginManager { + private final AppleSocialLogin appleSocialLogin; + private final GoogleSocialLogin googleSocialLogin; + + public IdTokenResponse getIdTokenByCode(SocialType type, String code) { + return switch (type) { + case APPLE -> appleSocialLogin.getIdTokenByCode(code); + case GOOGLE -> googleSocialLogin.getIdTokenByCode(code); + }; + } + + public String getUserInfo(IdTokenResponse tokenResponse) { + val idToken = tokenResponse.idToken(); + try { + val signedJWT = SignedJWT.parse(idToken); + val payload = signedJWT.getJWTClaimsSet(); + val userId = payload.getSubject(); + return userId; + } catch (ParseException e) { + throw new AuthException(INVALID_ID_TOKEN); + } + } +} diff --git a/operation-external/src/main/java/org/sopt/makers/operation/client/social/dto/IdTokenResponse.java b/operation-external/src/main/java/org/sopt/makers/operation/client/social/dto/IdTokenResponse.java new file mode 100644 index 00000000..2d92aa1c --- /dev/null +++ b/operation-external/src/main/java/org/sopt/makers/operation/client/social/dto/IdTokenResponse.java @@ -0,0 +1,9 @@ +package org.sopt.makers.operation.client.social.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record IdTokenResponse( + @JsonProperty("id_token") + String idToken +) { +}