Skip to content

Commit

Permalink
Merge pull request #260 from sopt-makers/feat/T-10855
Browse files Browse the repository at this point in the history
T-10855 [feat] GET /authorize API 구현
  • Loading branch information
KWY0218 authored May 31, 2024
2 parents fdcb577 + a7c381b commit 4f2dab2
Show file tree
Hide file tree
Showing 25 changed files with 798 additions and 1 deletion.
6 changes: 6 additions & 0 deletions .github/workflows/cd-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions operation-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
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
);
}
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;
}
}
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) {
}
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);
}
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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -74,4 +75,10 @@ public ResponseEntity<BaseResponse<?>> attendanceException(AttendanceException e
return ApiResponseUtil.failure(ex.getFailureCode());
}

@ExceptionHandler(AuthException.class)
public ResponseEntity<BaseResponse<?>> authException(AuthException ex) {
log.error(ex.getMessage());
return ApiResponseUtil.failure(ex.getFailureCode());
}

}
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 입니다."));
}
}
}
Loading

0 comments on commit 4f2dab2

Please sign in to comment.