-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #260 from sopt-makers/feat/T-10855
T-10855 [feat] GET /authorize API 구현
- Loading branch information
Showing
25 changed files
with
798 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
48 changes: 48 additions & 0 deletions
48
operation-api/src/main/java/org/sopt/makers/operation/auth/api/AuthApi.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<BaseResponse<?>> authorize( | ||
@RequestParam String type, | ||
@RequestParam String code, | ||
@RequestParam String clientId, | ||
@RequestParam String redirectUri | ||
); | ||
} |
60 changes: 60 additions & 0 deletions
60
operation-api/src/main/java/org/sopt/makers/operation/auth/api/AuthApiController.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String, String> tempPlatformCode = new ConcurrentHashMap<>(); | ||
private final AuthService authService; | ||
|
||
@Override | ||
@GetMapping("/api/v1/authorize") | ||
public ResponseEntity<BaseResponse<?>> 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; | ||
} | ||
} |
4 changes: 4 additions & 0 deletions
4
.../src/main/java/org/sopt/makers/operation/auth/dto/response/AuthorizationCodeResponse.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
package org.sopt.makers.operation.auth.dto.response; | ||
|
||
public record AuthorizationCodeResponse(String platformCode) { | ||
} |
13 changes: 13 additions & 0 deletions
13
operation-api/src/main/java/org/sopt/makers/operation/auth/service/AuthService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} |
72 changes: 72 additions & 0 deletions
72
operation-api/src/main/java/org/sopt/makers/operation/auth/service/AuthServiceImpl.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
130 changes: 130 additions & 0 deletions
130
operation-api/src/test/java/org/sopt/makers/operation/auth/api/AuthApiControllerTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 입니다.")); | ||
} | ||
} | ||
} |
Oops, something went wrong.