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 index ad898260..7ca616c9 100644 --- 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 @@ -3,6 +3,7 @@ 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.auth.dto.request.AccessTokenRequest; import org.sopt.makers.operation.dto.BaseResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestParam; @@ -45,4 +46,37 @@ ResponseEntity> authorize( @RequestParam String clientId, @RequestParam String redirectUri ); + + @Operation( + security = @SecurityRequirement(name = "Authorization"), + summary = "인증 토큰 발급 API", + responses = { + @ApiResponse( + responseCode = "200", + description = "토큰 발급 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1. grantType 데이터가 들어오지 않았습니다.\n + 2. 유효하지 않은 grantType 입니다.\n + 3. 플랫폼 인가코드가 들어오지 않았습니다.\n + 4. 이미 사용한 플랫폼 인가코드입니다.\n + 5. 리프레쉬 토큰이 들어오지 않았습니다. + """ + ), + @ApiResponse( + responseCode = "401", + description = """ + 1. 만료된 플랫폼 인가 코드입니다.\n + 2. 만료된 리프레쉬 토큰입니다. + """ + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류" + ) + } + ) + ResponseEntity> token(AccessTokenRequest accessTokenRequest); } 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 index 740b130b..f1e5aa21 100644 --- 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 @@ -2,29 +2,49 @@ import lombok.RequiredArgsConstructor; import lombok.val; +import org.sopt.makers.operation.auth.dto.request.AccessTokenRequest; import org.sopt.makers.operation.auth.dto.response.AuthorizationCodeResponse; +import org.sopt.makers.operation.auth.dto.response.TokenResponse; 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.jwt.JwtTokenProvider; import org.sopt.makers.operation.user.domain.SocialType; import org.sopt.makers.operation.util.ApiResponseUtil; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; 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.EXPIRED_PLATFORM_CODE; +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.EXPIRED_REFRESH_TOKEN; +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.INVALID_GRANT_TYPE; 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.failure.auth.AuthFailureCode.NOT_NULL_CODE; +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.NOT_NULL_GRANT_TYPE; +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.NOT_NULL_REFRESH_TOKEN; +import static org.sopt.makers.operation.code.failure.auth.AuthFailureCode.USED_PLATFORM_CODE; +import static org.sopt.makers.operation.code.success.auth.AuthSuccessCode.SUCCESS_GENERATE_TOKEN; import static org.sopt.makers.operation.code.success.auth.AuthSuccessCode.SUCCESS_GET_AUTHORIZATION_CODE; +import static org.sopt.makers.operation.jwt.JwtTokenType.PLATFORM_CODE; +import static org.sopt.makers.operation.jwt.JwtTokenType.REFRESH_TOKEN; @RestController @RequiredArgsConstructor public class AuthApiController implements AuthApi { - private final ConcurrentHashMap tempPlatformCode = new ConcurrentHashMap<>(); + private static final String AUTHORIZATION_CODE_GRANT_TYPE = "authorizationCode"; + private static final String REFRESH_TOKEN_GRANT_TYPE = "refreshToken"; + + private final ConcurrentHashMap tempPlatformCode; private final AuthService authService; + private final JwtTokenProvider jwtTokenProvider; @Override @GetMapping("/api/v1/authorize") @@ -46,6 +66,61 @@ public ResponseEntity> authorize( return ApiResponseUtil.success(SUCCESS_GET_AUTHORIZATION_CODE, new AuthorizationCodeResponse(platformCode)); } + @Override + @PostMapping( + path = "/api/v1/token", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE + ) + public ResponseEntity> token(AccessTokenRequest accessTokenRequest) { + if (accessTokenRequest.isNullGrantType()) { + throw new AuthException(NOT_NULL_GRANT_TYPE); + } + + val grantType = accessTokenRequest.grantType(); + if (!(grantType.equals(AUTHORIZATION_CODE_GRANT_TYPE) || grantType.equals(REFRESH_TOKEN_GRANT_TYPE))) { + throw new AuthException(INVALID_GRANT_TYPE); + } + + val tokenResponse = grantType.equals(AUTHORIZATION_CODE_GRANT_TYPE) ? + generateTokenResponseByAuthorizationCode(accessTokenRequest) : generateTokenResponseByRefreshToken(accessTokenRequest); + return ApiResponseUtil.success(SUCCESS_GENERATE_TOKEN, tokenResponse); + } + + private TokenResponse generateTokenResponseByAuthorizationCode(AccessTokenRequest accessTokenRequest) { + if (accessTokenRequest.isNullCode()) { + throw new AuthException(NOT_NULL_CODE); + } + if (!tempPlatformCode.contains(accessTokenRequest.code())) { + throw new AuthException(USED_PLATFORM_CODE); + } + tempPlatformCode.remove(accessTokenRequest.code()); + + if (!jwtTokenProvider.validatePlatformCode(accessTokenRequest.code(), accessTokenRequest.clientId(), accessTokenRequest.redirectUri())) { + throw new AuthException(EXPIRED_PLATFORM_CODE); + } + + val authentication = jwtTokenProvider.getAuthentication(accessTokenRequest.code(), PLATFORM_CODE); + return generateTokenResponse(authentication); + } + + private TokenResponse generateTokenResponseByRefreshToken(AccessTokenRequest accessTokenRequest) { + if (accessTokenRequest.isNullRefreshToken()) { + throw new AuthException(NOT_NULL_REFRESH_TOKEN); + } + if (!jwtTokenProvider.validateTokenExpiration(accessTokenRequest.refreshToken(), REFRESH_TOKEN)) { + throw new AuthException(EXPIRED_REFRESH_TOKEN); + } + + val authentication = jwtTokenProvider.getAuthentication(accessTokenRequest.refreshToken(), REFRESH_TOKEN); + return generateTokenResponse(authentication); + } + + private TokenResponse generateTokenResponse(Authentication authentication) { + val accessToken = jwtTokenProvider.generateAccessToken(authentication); + val refreshToken = jwtTokenProvider.generateRefreshToken(authentication); + return TokenResponse.of(accessToken, refreshToken); + } + private Long findUserIdBySocialTypeAndCode(String type, String code) { val socialType = SocialType.valueOf(type); val userSocialId = authService.getSocialUserInfo(socialType, code); @@ -53,7 +128,7 @@ private Long findUserIdBySocialTypeAndCode(String type, String code) { } private String generatePlatformCode(String clientId, String redirectUri, Long userId) { - val platformCode = authService.generatePlatformCode(clientId, redirectUri, userId); + val platformCode = jwtTokenProvider.generatePlatformCode(clientId, redirectUri, userId); tempPlatformCode.putIfAbsent(platformCode, platformCode); return platformCode; } diff --git a/operation-api/src/main/java/org/sopt/makers/operation/auth/dto/request/AccessTokenRequest.java b/operation-api/src/main/java/org/sopt/makers/operation/auth/dto/request/AccessTokenRequest.java new file mode 100644 index 00000000..045b4365 --- /dev/null +++ b/operation-api/src/main/java/org/sopt/makers/operation/auth/dto/request/AccessTokenRequest.java @@ -0,0 +1,21 @@ +package org.sopt.makers.operation.auth.dto.request; + +public record AccessTokenRequest( + String grantType, + String clientId, + String redirectUri, + String code, + String refreshToken +) { + public boolean isNullGrantType() { + return grantType == null; + } + + public boolean isNullCode() { + return code == null; + } + + public boolean isNullRefreshToken() { + return refreshToken == null; + } +} diff --git a/operation-api/src/main/java/org/sopt/makers/operation/auth/dto/response/TokenResponse.java b/operation-api/src/main/java/org/sopt/makers/operation/auth/dto/response/TokenResponse.java new file mode 100644 index 00000000..faeb3436 --- /dev/null +++ b/operation-api/src/main/java/org/sopt/makers/operation/auth/dto/response/TokenResponse.java @@ -0,0 +1,9 @@ +package org.sopt.makers.operation.auth.dto.response; + +public record TokenResponse(String tokenType, String accessToken, String refreshToken) { + private static final String BEARER_TOKEN_TYPE = "Bearer"; + + public static TokenResponse of(String accessToken, String refreshToken) { + return new TokenResponse(BEARER_TOKEN_TYPE, accessToken, refreshToken); + } +} 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 index aca12053..0500d42a 100644 --- 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 @@ -8,6 +8,4 @@ public interface AuthService { 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 index 486e38aa..c5211228 100644 --- 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 @@ -1,22 +1,14 @@ 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; @@ -24,12 +16,9 @@ @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) { @@ -51,22 +40,4 @@ public Long getUserId(SocialType socialType, String 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/config/ConcurrentHashMapConfig.java b/operation-api/src/main/java/org/sopt/makers/operation/common/config/ConcurrentHashMapConfig.java new file mode 100644 index 00000000..d7cc9f6a --- /dev/null +++ b/operation-api/src/main/java/org/sopt/makers/operation/common/config/ConcurrentHashMapConfig.java @@ -0,0 +1,14 @@ +package org.sopt.makers.operation.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.ConcurrentHashMap; + +@Configuration +public class ConcurrentHashMapConfig { + @Bean + public ConcurrentHashMap registerConcurrentHashMap() { + return new ConcurrentHashMap<>(); + } +} 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 index 1706a681..64ba7938 100644 --- 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 @@ -7,7 +7,9 @@ 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.authentication.AdminAuthentication; import org.sopt.makers.operation.common.handler.ErrorHandler; +import org.sopt.makers.operation.jwt.JwtTokenProvider; import org.sopt.makers.operation.user.domain.SocialType; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; @@ -18,8 +20,13 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.web.servlet.MockMvc; +import java.util.concurrent.ConcurrentHashMap; + import static org.mockito.BDDMockito.given; +import static org.sopt.makers.operation.jwt.JwtTokenType.PLATFORM_CODE; +import static org.sopt.makers.operation.jwt.JwtTokenType.REFRESH_TOKEN; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -29,11 +36,16 @@ class AuthApiControllerTest { @MockBean AuthService authService; + @MockBean + JwtTokenProvider jwtTokenProvider; + @MockBean + ConcurrentHashMap tempPlatformCode; @Autowired MockMvc mockMvc; @Autowired ObjectMapper objectMapper; - final String uri = "/api/v1/authorize"; + final String authorizeUri = "/api/v1/authorize"; + final String tokenUri = "/api/v1/token"; @Nested @DisplayName("API 통신 성공 테스트") @@ -49,10 +61,10 @@ void successTest(String type, String code, String clientId, String redirectUri) 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"); + given(jwtTokenProvider.generatePlatformCode(clientId, redirectUri, 1L)).willReturn("Platform Code"); // when, then - mockMvc.perform(get(uri) + mockMvc.perform(get(authorizeUri) .contentType(MediaType.APPLICATION_JSON) .param("type", type) .param("code", code) @@ -61,10 +73,64 @@ void successTest(String type, String code, String clientId, String redirectUri) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("플랫폼 인가코드 발급 성공")); } + + @DisplayName("grantType 이 authorizationCode 이고, 유효한 clientId, redirectUri, code 값이 들어왔을 때, 액세스 토큰과 리프레시 토큰을 발급한다.") + @ParameterizedTest + @CsvSource({ + "authorizationCode,clientId,redirectUri,code" + }) + void tokenByAuthorizationCodeSuccessTest(String grantType, String clientId, String redirectUri, String code) throws Exception { + // given + val authentication = new AdminAuthentication(1L, null, null); + given(jwtTokenProvider.validatePlatformCode(code, clientId, redirectUri)).willReturn(true); + given(jwtTokenProvider.getAuthentication(code, PLATFORM_CODE)).willReturn(authentication); + given(jwtTokenProvider.generateAccessToken(authentication)).willReturn("access token"); + given(jwtTokenProvider.generateRefreshToken(authentication)).willReturn("refresh token"); + given(tempPlatformCode.contains(code)).willReturn(true); + + // when, then + mockMvc.perform(post(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grantType", grantType) + .param("clientId", clientId) + .param("redirectUri", redirectUri) + .param("code", code)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("토큰 발급 성공")) + .andExpect(jsonPath("$.data.tokenType").value("Bearer")) + .andExpect(jsonPath("$.data.accessToken").value("access token")) + .andExpect(jsonPath("$.data.refreshToken").value("refresh token")); + } + + @DisplayName("grantType 이 refreshToken 이고, 유효한 refreshToken 값이 들어왔을 때, 액세스 토큰과 리프레시 토큰을 발급한다.") + @ParameterizedTest + @CsvSource({ + "refreshToken,refreshToken" + }) + void tokenByRefreshTokenSuccessTest(String grantType, String refreshToken) throws Exception { + // given + val authentication = new AdminAuthentication(1L, null, null); + given(jwtTokenProvider.validateTokenExpiration(refreshToken, REFRESH_TOKEN)).willReturn(true); + given(jwtTokenProvider.getAuthentication(refreshToken, REFRESH_TOKEN)).willReturn(authentication); + given(jwtTokenProvider.generateAccessToken(authentication)).willReturn("access token"); + given(jwtTokenProvider.generateRefreshToken(authentication)).willReturn("refresh token"); + + // when, then + mockMvc.perform(post(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grantType", grantType) + .param("refreshToken", refreshToken) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("토큰 발급 성공")) + .andExpect(jsonPath("$.data.tokenType").value("Bearer")) + .andExpect(jsonPath("$.data.accessToken").value("access token")) + .andExpect(jsonPath("$.data.refreshToken").value("refresh token")); + } } @Nested - @DisplayName("쿼리 파라미터 유효성 검사 테스트") + @DisplayName("/authorize API 쿼리 파라미터 유효성 검사 테스트") class QueryParameterValidateTest { @DisplayName("type, code, clientId, redirectUri 중 하나라도 null 이 들어오면 400을 반환한다.") @@ -77,7 +143,7 @@ class QueryParameterValidateTest { }) void validateTest(String type, String code, String clientId, String redirectUri) throws Exception { // when, then - mockMvc.perform(get(uri) + mockMvc.perform(get(authorizeUri) .contentType(MediaType.APPLICATION_JSON) .param("type", type) .param("code", code) @@ -96,7 +162,7 @@ void validateTest2(String type, String code, String clientId, String redirectUri given(authService.checkRegisteredTeamOAuthInfo(clientId, redirectUri)).willReturn(false); // when, then - mockMvc.perform(get(uri) + mockMvc.perform(get(authorizeUri) .contentType(MediaType.APPLICATION_JSON) .param("type", type) .param("code", code) @@ -117,7 +183,7 @@ void validateTest3(String type, String code, String clientId, String redirectUri given(authService.checkRegisteredTeamOAuthInfo(clientId, redirectUri)).willReturn(true); // when, then - mockMvc.perform(get(uri) + mockMvc.perform(get(authorizeUri) .contentType(MediaType.APPLICATION_JSON) .param("type", type) .param("code", code) @@ -127,4 +193,130 @@ void validateTest3(String type, String code, String clientId, String redirectUri .andExpect(jsonPath("$.message").value("유효하지 않은 social type 입니다.")); } } + + @Nested + @DisplayName("/token API 오류 케이스 테스트") + class TokenAPIErrorCaseTest { + + @DisplayName("grantType 이 null 일 때 400 에러를 반환한다.") + @ParameterizedTest + @CsvSource({ + ",clientId,redirectUri,code" + }) + void errorTest(String grantType, String clientId, String redirectUri, String code) throws Exception { + // when, then + mockMvc.perform(post(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grantType", grantType) + .param("clientId", clientId) + .param("redirectUri", redirectUri) + .param("code", code)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("grantType 데이터가 들어오지 않았습니다.")); + } + + @DisplayName("grantType 이 authorizationCode 또는 refreshToken 외의 값이 들어온다면 400 에러를 반환한다.") + @ParameterizedTest + @CsvSource({ + "error,clientId,redirectUri,code" + }) + void errorTest2(String grantType, String clientId, String redirectUri, String code) throws Exception { + // when, then + mockMvc.perform(post(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grantType", grantType) + .param("clientId", clientId) + .param("redirectUri", redirectUri) + .param("code", code)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("유효하지 않은 grantType 입니다.")); + } + + @DisplayName("grantType 이 authorizationCode 일 때, code 값으로 null 값이 들어온다면 400 에러를 반환한다.") + @ParameterizedTest + @CsvSource({ + "authorizationCode,clientId,redirectUri," + }) + void errorTest3(String grantType, String clientId, String redirectUri, String code) throws Exception { + // when, then + mockMvc.perform(post(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grantType", grantType) + .param("clientId", clientId) + .param("redirectUri", redirectUri) + .param("code", code)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("플랫폼 인가코드가 들어오지 않았습니다.")); + } + + @DisplayName("grantType 이 authorizationCode 일 때, code 값이 hashmap인 tempPlatformCode 내에 없다면 400 에러를 반환한다.") + @ParameterizedTest + @CsvSource({ + "authorizationCode,clientId,redirectUri,code" + }) + void errorTest4(String grantType, String clientId, String redirectUri, String code) throws Exception { + // given + given(tempPlatformCode.contains(code)).willReturn(false); + + // when, then + mockMvc.perform(post(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grantType", grantType) + .param("clientId", clientId) + .param("redirectUri", redirectUri) + .param("code", code)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("이미 사용한 플랫폼 인가코드입니다.")); + } + + @DisplayName("grantType 이 refreshToken 일 때, refreshToken 값으로 null 값이 들어온다면 400 에러를 반환한다.") + @ParameterizedTest + @CsvSource({ + "refreshToken," + }) + void errorTest5(String grantType, String refreshToken) throws Exception { + // when, then + mockMvc.perform(post(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grantType", grantType) + .param("refreshToken", refreshToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("리프레쉬 토큰이 들어오지 않았습니다.")); + } + + @DisplayName("grantType 이 authorizationCode 일 때, code 값이 만료되었다면 401 에러를 반환한다.") + @ParameterizedTest + @CsvSource({ + "authorizationCode,clientId,redirectUri,code" + }) + void errorTest6(String grantType, String clientId, String redirectUri, String code) throws Exception { + given(jwtTokenProvider.validatePlatformCode(code, clientId, redirectUri)).willReturn(false); + given(tempPlatformCode.contains(code)).willReturn(true); + // when, then + mockMvc.perform(post(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grantType", grantType) + .param("clientId", clientId) + .param("redirectUri", redirectUri) + .param("code", code)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("만료된 플랫폼 인가코드입니다.")); + } + + @DisplayName("grantType 이 refreshToken 일 때, refreshToken 값이 만료되었다면 401 에러를 반환한다.") + @ParameterizedTest + @CsvSource({ + "refreshToken,refreshToken" + }) + void errorTest7(String grantType, String refreshToken) throws Exception { + given(jwtTokenProvider.validateTokenExpiration(refreshToken, REFRESH_TOKEN)).willReturn(false); + // when, then + mockMvc.perform(post(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grantType", grantType) + .param("refreshToken", refreshToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("만료된 리프레쉬 토큰입니다.")); + } + } } \ 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 index 66e7292f..8e26991a 100644 --- 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 @@ -1,9 +1,5 @@ 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; @@ -14,14 +10,12 @@ 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; @@ -33,14 +27,12 @@ class AuthServiceTest { TeamOAuthInfoRepository teamOAuthInfoRepository; @Mock UserIdentityInfoRepository userIdentityInfoRepository; - @Mock - ValueConfig valueConfig; AuthService authService; @BeforeEach void setUp() { - authService = new AuthServiceImpl(socialLoginManager, teamOAuthInfoRepository, userIdentityInfoRepository, valueConfig); + authService = new AuthServiceImpl(socialLoginManager, teamOAuthInfoRepository, userIdentityInfoRepository); } @Nested @@ -79,36 +71,4 @@ void test() { } } - @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-api/src/test/java/org/sopt/makers/operation/jwt/JwtTokenProviderTest.java b/operation-api/src/test/java/org/sopt/makers/operation/jwt/JwtTokenProviderTest.java new file mode 100644 index 00000000..035b9cfd --- /dev/null +++ b/operation-api/src/test/java/org/sopt/makers/operation/jwt/JwtTokenProviderTest.java @@ -0,0 +1,99 @@ +package org.sopt.makers.operation.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +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 java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class JwtTokenProviderTest { + final String platformSecretKey = "123456789123456789123456789123456789123456789123456789"; + JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setUp() { + jwtTokenProvider = new JwtTokenProvider("", "", "", platformSecretKey); + } + + @Nested + @DisplayName("generatePlatformCode 메서드 테스트") + class GeneratePlatformCodeMethodTest { + + @DisplayName("iss:clientId, aud:redirectUri , sub:userId 인 jwt 토큰을 발급한다.") + @Test + void test() { + // given + val clientId = "clientId"; + val redirectUri = "redirectUri"; + val userId = 1L; + + // when + String platformCode = jwtTokenProvider.generatePlatformCode(clientId, redirectUri, userId); + val claims = getClaimsFromToken(platformCode); + + // then + assertThat(claims.getIssuer()).isEqualTo("clientId"); + assertThat(claims.getAudience()).isEqualTo("redirectUri"); + assertThat(claims.getSubject()).isEqualTo("1"); + } + + private Claims getClaimsFromToken(String token) { + val encodedKey = encodeKey(platformSecretKey); + + return Jwts.parserBuilder() + .setSigningKey(encodedKey) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private String encodeKey(String secretKey) { + return Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8)); + } + } + + @Nested + @DisplayName("validatePlatformCode 메서드 테스트") + class ValidatePlatformCodeMethodTest { + + @DisplayName("iss:clientId, aud:redirectUri , sub:userId 인 jwt 토큰과 clientId, redirectUri 를 아규먼트로 넣으면 true 를 반환한다.") + @Test + void test() { + // given + val clientId = "clientId"; + val redirectUri = "redirectUri"; + val userId = 1L; + String platformCode = jwtTokenProvider.generatePlatformCode(clientId, redirectUri, userId); + + // when + val result = jwtTokenProvider.validatePlatformCode(platformCode, "clientId", "redirectUri"); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("iss:clientId, aud:redirectUri , sub:userId 인 jwt 토큰과 notClientId, notRedirectUri 를 아규먼트로 넣으면 false 를 반환한다.") + @Test + void test2() { + // given + val clientId = "clientId"; + val redirectUri = "redirectUri"; + val userId = 1L; + String platformCode = jwtTokenProvider.generatePlatformCode(clientId, redirectUri, userId); + + // when + val result = jwtTokenProvider.validatePlatformCode(platformCode, "notClientId", "notRedirectUri"); + + // then + assertThat(result).isFalse(); + } + } + +} \ No newline at end of file diff --git a/operation-auth/src/main/java/org/sopt/makers/operation/jwt/JwtTokenProvider.java b/operation-auth/src/main/java/org/sopt/makers/operation/jwt/JwtTokenProvider.java index ae249d01..6945a461 100644 --- a/operation-auth/src/main/java/org/sopt/makers/operation/jwt/JwtTokenProvider.java +++ b/operation-auth/src/main/java/org/sopt/makers/operation/jwt/JwtTokenProvider.java @@ -1,7 +1,5 @@ package org.sopt.makers.operation.jwt; -import static org.sopt.makers.operation.code.failure.TokenFailureCode.*; - import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -19,7 +17,6 @@ import javax.crypto.spec.SecretKeySpec; import javax.xml.bind.DatatypeConverter; - import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.ZoneId; @@ -28,18 +25,42 @@ import java.util.HashMap; import java.util.Map; -@RequiredArgsConstructor +import static org.sopt.makers.operation.code.failure.TokenFailureCode.INVALID_TOKEN; + @Service public class JwtTokenProvider { - @Value("${spring.jwt.secretKey.access}") - private String accessSecretKey; + private final String accessSecretKey; + private final String refreshSecretKey; + private final String appAccessSecretKey; + private final String platformCodeSecretKey; + + public JwtTokenProvider( + @Value("${spring.jwt.secretKey.access}") String accessSecretKey, + @Value("${spring.jwt.secretKey.refresh}") String refreshSecretKey, + @Value("${spring.jwt.secretKey.app}") String appAccessSecretKey, + @Value("${spring.jwt.secretKey.platform_code}") String platformCodeSecretKey + ) { + this.accessSecretKey = accessSecretKey; + this.refreshSecretKey = refreshSecretKey; + this.appAccessSecretKey = appAccessSecretKey; + this.platformCodeSecretKey = platformCodeSecretKey; + } - @Value("${spring.jwt.secretKey.refresh}") - private String refreshSecretKey; + public String generatePlatformCode(final String clientId, final String redirectUri, final Long userId) { + val encodeKey = encodeKey(platformCodeSecretKey); + val secretKeyBytes = DatatypeConverter.parseBase64Binary(encodeKey); + val signingKey = new SecretKeySpec(secretKeyBytes, SignatureAlgorithm.HS256.getJcaName()); - @Value("${spring.jwt.secretKey.app}") - private String appAccessSecretKey; + return Jwts.builder() + .setHeader(createHeader()) + .setIssuer(clientId) + .setAudience(redirectUri) + .setSubject(Long.toString(userId)) + .setExpiration(createExpireDate(JwtTokenType.PLATFORM_CODE)) + .signWith(signingKey, SignatureAlgorithm.HS256) + .compact(); + } public String generateAccessToken(final Authentication authentication) { val encodedKey = encodeKey(accessSecretKey); @@ -76,9 +97,19 @@ public boolean validateTokenExpiration(String token, JwtTokenType jwtTokenType) } } + public boolean validatePlatformCode(String platformCode, String clientId, String redirectUri) { + try { + val claims = getClaimsFromToken(platformCode, JwtTokenType.PLATFORM_CODE); + return isClaimsMatchingRequest(claims, clientId, redirectUri); + } catch (ExpiredJwtException | SignatureException e) { + return false; + } + } + public AdminAuthentication getAuthentication(String token, JwtTokenType jwtTokenType) { return switch (jwtTokenType) { - case ACCESS_TOKEN, REFRESH_TOKEN -> new AdminAuthentication(getId(token, jwtTokenType), null, null); + case ACCESS_TOKEN, REFRESH_TOKEN, PLATFORM_CODE -> + new AdminAuthentication(getId(token, jwtTokenType), null, null); case APP_ACCESS_TOKEN -> new AdminAuthentication(getPlayGroundId(token, jwtTokenType), null, null); }; } @@ -103,6 +134,11 @@ public Long getId(String token, JwtTokenType jwtTokenType) { } } + private boolean isClaimsMatchingRequest(Claims claims, String clientId, String redirectUri) { + return claims.getAudience().equals(redirectUri) + && claims.getIssuer().equals(clientId); + } + private Claims getClaimsFromToken(String token, JwtTokenType jwtTokenType) { val encodedKey = encodeKey(setSecretKey(jwtTokenType)); @@ -131,6 +167,7 @@ private String setSecretKey(JwtTokenType jwtTokenType) { case ACCESS_TOKEN -> accessSecretKey; case REFRESH_TOKEN -> refreshSecretKey; case APP_ACCESS_TOKEN -> appAccessSecretKey; + case PLATFORM_CODE -> platformCodeSecretKey; }; } @@ -139,6 +176,7 @@ private LocalDateTime setExpireTime(LocalDateTime now, JwtTokenType jwtTokenType case ACCESS_TOKEN -> now.plusHours(5); case REFRESH_TOKEN -> now.plusWeeks(2); case APP_ACCESS_TOKEN -> throw new TokenException(INVALID_TOKEN); + case PLATFORM_CODE -> now.plusMinutes(5); }; } diff --git a/operation-auth/src/main/java/org/sopt/makers/operation/jwt/JwtTokenType.java b/operation-auth/src/main/java/org/sopt/makers/operation/jwt/JwtTokenType.java index 385ec839..2bb24ef4 100644 --- a/operation-auth/src/main/java/org/sopt/makers/operation/jwt/JwtTokenType.java +++ b/operation-auth/src/main/java/org/sopt/makers/operation/jwt/JwtTokenType.java @@ -1,5 +1,5 @@ package org.sopt.makers.operation.jwt; public enum JwtTokenType { - ACCESS_TOKEN, REFRESH_TOKEN, APP_ACCESS_TOKEN + ACCESS_TOKEN, REFRESH_TOKEN, APP_ACCESS_TOKEN, PLATFORM_CODE } 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 index b519a879..d1cf4c1a 100644 --- 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 @@ -7,16 +7,24 @@ import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; @Getter @RequiredArgsConstructor public enum AuthFailureCode implements FailureCode { // 400 - NOT_NULL_PARAMS(BAD_REQUEST, "쿼리 파라미터 중 데이터가 들어오지 않았습니다."), + NOT_NULL_GRANT_TYPE(BAD_REQUEST, "grantType 데이터가 들어오지 않았습니다."), 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 읽기 실패"), + INVALID_GRANT_TYPE(BAD_REQUEST, "유효하지 않은 grantType 입니다."), + NOT_NULL_CODE(BAD_REQUEST, "플랫폼 인가코드가 들어오지 않았습니다."), + USED_PLATFORM_CODE(BAD_REQUEST, "이미 사용한 플랫폼 인가코드입니다."), + NOT_NULL_REFRESH_TOKEN(BAD_REQUEST, "리프레쉬 토큰이 들어오지 않았습니다."), + // 401 + EXPIRED_PLATFORM_CODE(UNAUTHORIZED, "만료된 플랫폼 인가코드입니다."), + EXPIRED_REFRESH_TOKEN(UNAUTHORIZED, "만료된 리프레쉬 토큰입니다."), // 404 NOT_FOUNT_REGISTERED_TEAM(NOT_FOUND, "등록되지 않은 팀입니다."), NOT_FOUND_USER_SOCIAL_IDENTITY_INFO(NOT_FOUND, "등록된 소셜 정보가 없습니다."), 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 index d86b1081..9801b921 100644 --- 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 @@ -10,7 +10,8 @@ @Getter @RequiredArgsConstructor public enum AuthSuccessCode implements SuccessCode { - SUCCESS_GET_AUTHORIZATION_CODE(OK, "플랫폼 인가코드 발급 성공"); + SUCCESS_GET_AUTHORIZATION_CODE(OK, "플랫폼 인가코드 발급 성공"), + SUCCESS_GENERATE_TOKEN(OK, "토큰 발급 성공"); private final HttpStatus status; private final String message; }