Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(SignUp): 카카오 회원가입, jwt 로그인 구현 #21

Merged
merged 24 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1854e11
Feat(SignUp): 카카오 idToken기반 회원가입 구현
InHyeok-J Jun 30, 2024
f649189
Feat(Login): token 발급 로직 구현
Jun 30, 2024
cf56758
Merge branch 'develop' into feature/SWM-54-signup
Jun 30, 2024
ab5c77d
Refactor(*): 아키텍처 구조 변경
InHyeok-J Jul 11, 2024
bde8448
Feat(Oauth): OauthServertype 변경
InHyeok-J Jul 11, 2024
39b9c1d
Refactor(Login): login usecase 수정
InHyeok-J Jul 11, 2024
b5309a3
Feat(Login): Token, Publickey cache 적용
InHyeok-J Jul 11, 2024
9fd0e04
Fix(yml): yaml 값 오타 수정
InHyeok-J Jul 11, 2024
74ef6e0
Fix(test): oauthserver type test코드 버그 수정
InHyeok-J Jul 11, 2024
7e40366
Feat(SignUp): kakao가입시 email도 받도록 수정
InHyeok-J Jul 11, 2024
6a29265
Feat(test): kakaoidTokenValidator 테스트 코드 추가
InHyeok-J Jul 11, 2024
ab2203c
Fix(*): LoginUseCase 트랜잭셔널 추가
InHyeok-J Jul 12, 2024
1b8578c
Feat(*): validator 변수 이름 변경
InHyeok-J Jul 12, 2024
c88280e
Refactor(*): kakao 관련 상수 값 static 추가
InHyeok-J Jul 12, 2024
5761d6c
Fix(*): 불필요 메소드 static 제거
InHyeok-J Jul 12, 2024
3b93569
Refactor(*): login usecase 코드 수정
InHyeok-J Jul 12, 2024
4f50d74
Refactor(*): error code 401 name 변경
InHyeok-J Jul 12, 2024
fcf3ca1
Feat(kakao): 카카오 jwk 호출시 예외 처리 및 테스트 추가
InHyeok-J Jul 12, 2024
ea9b1dd
Docs(*): 미구현 기능 Todo 추가
InHyeok-J Jul 12, 2024
a52d88d
Feat(*): Enum 검증 모듈 추가
InHyeok-J Jul 12, 2024
f1933d8
Test(login): login usecase 유닛 테스트 추가
InHyeok-J Jul 12, 2024
d9a379b
Test(*): Member 닉네임 생성기 test 추가
InHyeok-J Jul 12, 2024
bbbac3c
Fix(*): display 설명 수정
InHyeok-J Jul 15, 2024
6917cd7
Refactor(*): enum validtor 가독성 수정
InHyeok-J Jul 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@

import com.jabiseo.cache.RedisCacheRepository;
import com.jabiseo.client.KakaoKauthClient;
import com.jabiseo.client.NetworkApiException;
import com.jabiseo.client.OidcPublicKey;
import com.jabiseo.client.OidcPublicKeyResponse;
import com.jabiseo.auth.exception.AuthenticationBusinessException;
import com.jabiseo.auth.exception.AuthenticationErrorCode;
import com.jabiseo.auth.application.oidc.property.KakaoOidcProperty;
import com.jabiseo.exception.CommonErrorCode;
import com.jabiseo.member.domain.OauthServer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

@Slf4j
@Component
public class KakaoIdTokenValidator extends AbstractIdTokenValidator {

Expand All @@ -34,8 +38,7 @@ protected OidcPublicKey getOidcPublicKey(String kid) {

List<OidcPublicKey> keys = redisCacheRepository.getPublicKeys(CACHE_KEY);
if (keys == null) {
OidcPublicKeyResponse publicKeys = kakaoKauthClient.getPublicKeys().getBody();
keys = publicKeys.getKeys();
keys = getOidcPublicKeyByKakaoClient();
redisCacheRepository.savePublicKey(CACHE_KEY, keys);
}

Expand All @@ -44,6 +47,16 @@ protected OidcPublicKey getOidcPublicKey(String kid) {
.orElseThrow(() -> new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_ID_TOKEN));
}

private List<OidcPublicKey> getOidcPublicKeyByKakaoClient() {
try {
ResponseEntity<OidcPublicKeyResponse> publicKeys = kakaoKauthClient.getPublicKeys();
return publicKeys.getBody().getKeys();
} catch (NetworkApiException e) {
log.error(e.getMessage());
throw new AuthenticationBusinessException(AuthenticationErrorCode.GET_JWK_FAIL);
}
}

@Override
protected OauthMemberInfo extractMemberInfoFromPayload(Map<String, Object> payload) {
String oauthId = (String) payload.get(KAKAO_ID_KEY);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.jabiseo.cache.RedisCacheRepository;
import com.jabiseo.member.domain.Member;
import com.jabiseo.member.domain.MemberRepository;
import com.jabiseo.member.domain.OauthServer;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand All @@ -26,7 +27,7 @@ public class LoginUseCase {
private final RedisCacheRepository cacheRepository;

public LoginResponse execute(LoginRequest loginRequest) {
OauthMemberInfo oauthMemberInfo = tokenValidatorManager.validate(loginRequest.idToken(), loginRequest.oauthServer());
OauthMemberInfo oauthMemberInfo = tokenValidatorManager.validate(loginRequest.idToken(), OauthServer.valueOf(loginRequest.oauthServer()));

Member member = memberRepository.findByOauthIdAndOauthServer(oauthMemberInfo.getOauthId(), oauthMemberInfo.getOauthServer())
.orElseGet(() -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.jabiseo.auth.application.usecase.LogoutUseCase;
import com.jabiseo.auth.application.usecase.ReissueUseCase;
import com.jabiseo.auth.application.usecase.WithdrawUseCase;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
Expand All @@ -27,7 +28,7 @@ public class AuthController {
private final WithdrawUseCase withdrawUseCase;

@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest) {
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest loginRequest) {
LoginResponse result = loginUseCase.execute(loginRequest);
return ResponseEntity.ok(result);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package com.jabiseo.auth.dto;

import com.jabiseo.common.EnumValid;
import com.jabiseo.member.domain.OauthServer;
import jakarta.validation.constraints.NotNull;

public record LoginRequest(@NotNull String idToken, @NotNull OauthServer oauthServer) {
public record LoginRequest(
@NotNull
String idToken,
@EnumValid(enumClass = OauthServer.class, message = "oauthServer Type이 잘못됐습니다.")
String oauthServer) {
}
29 changes: 29 additions & 0 deletions jabiseo-api/src/main/java/com/jabiseo/common/EnumValid.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.jabiseo.common;
InHyeok-J marked this conversation as resolved.
Show resolved Hide resolved



import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.*;

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
InHyeok-J marked this conversation as resolved.
Show resolved Hide resolved
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = ValueOfEnumValidator.class)
public @interface EnumValid {
Class<? extends Enum<?>> enumClass();

String message() default "";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};

boolean ignoreCase() default false;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.jabiseo.common;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ValueOfEnumValidator implements ConstraintValidator<EnumValid, String> {

private EnumValid enumValid;

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
boolean result = false;
if(value == null){
return false;
}

Enum<?>[] enumValues = this.enumValid.enumClass().getEnumConstants();
if (enumValues != null) {
for (Object enumValue : enumValues) {
if (value.equals(enumValue.toString())
|| this.enumValid.ignoreCase() && value.equalsIgnoreCase(enumValue.toString())) {
result = true;
break;
}
}
}
return result;
InHyeok-J marked this conversation as resolved.
Show resolved Hide resolved
}

@Override
public void initialize(EnumValid constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
this.enumValid = constraintAnnotation;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.jabiseo.auth.exception.AuthenticationErrorCode;
import com.jabiseo.cache.RedisCacheRepository;
import com.jabiseo.client.KakaoKauthClient;
import com.jabiseo.client.NetworkApiException;
import com.jabiseo.client.OidcPublicKey;
import com.jabiseo.client.OidcPublicKeyResponse;
import org.assertj.core.api.Assertions;
Expand All @@ -27,6 +28,7 @@
import static org.mockito.Mockito.verify;


@DisplayName("카카오IdToken검증 테스트")
@ExtendWith(MockitoExtension.class)
class KakaoIdTokenValidatorTest {

Expand Down Expand Up @@ -109,6 +111,20 @@ void SuccessMatchKidReturnOidcPublicKey() {
Assertions.assertThat(oidcPublicKey).isEqualTo(matchPublicKey);
}

@Test
@DisplayName("카카오 jwk 획득 api 호출 실패시 에러를 반환한다")
void getJwkKakaoApiCallingFailThrownExcetpion(){
//given
given(redisCacheRepository.getPublicKeys(any())).willReturn(null);
given(kakaoKauthClient.getPublicKeys()).willThrow(NetworkApiException.class);

//when then
assertThatThrownBy(() -> validator.getOidcPublicKey("key"))
.isInstanceOf(AuthenticationBusinessException.class)
.hasMessage(AuthenticationErrorCode.GET_JWK_FAIL.getMessage());

}

private OidcPublicKey mockPublicKey(String kid) {
return new OidcPublicKey(kid, "a", "u", "n", "e");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.jabiseo.auth.application.usecase;

import com.jabiseo.auth.application.JwtHandler;
import com.jabiseo.auth.application.MemberFactory;
import com.jabiseo.auth.application.oidc.OauthMemberInfo;
import com.jabiseo.auth.application.oidc.TokenValidatorManager;
import com.jabiseo.auth.dto.LoginRequest;
import com.jabiseo.auth.dto.LoginResponse;
import com.jabiseo.cache.RedisCacheRepository;
import com.jabiseo.fixture.MemberFixture;
import com.jabiseo.member.domain.Member;
import com.jabiseo.member.domain.MemberRepository;
import com.jabiseo.member.domain.OauthServer;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

@DisplayName("login usecase 테스트")
@ExtendWith(MockitoExtension.class)
class LoginUseCaseTest {

@InjectMocks
LoginUseCase loginUseCase;

@Mock
TokenValidatorManager tokenValidatorManager;

@Mock
MemberFactory memberFactory;

@Mock
JwtHandler jwtHandler;

@Mock
MemberRepository memberRepository;

@Mock
RedisCacheRepository redisCacheRepository;


@Test
@DisplayName("처음 요청 오는 OAuth 회원일시 맴버 객체를 생성하고 저장한다.")
InHyeok-J marked this conversation as resolved.
Show resolved Hide resolved
void firstOauthUserIsSignUpAndSave() {
//given
LoginRequest request = new LoginRequest("idToken", "KAKAO");
OauthMemberInfo memberInfo = new OauthMemberInfo("id", OauthServer.KAKAO, "[email protected]");
Member member = MemberFixture.createMember("memberId");
given(memberRepository.findByOauthIdAndOauthServer(memberInfo.getOauthId(), memberInfo.getOauthServer())).willReturn(Optional.empty());
given(tokenValidatorManager.validate(request.idToken(), OauthServer.valueOf(request.oauthServer()))).willReturn(memberInfo);
given(memberFactory.createNew(memberInfo)).willReturn(member);
given(memberRepository.save(any())).willReturn(member);

//when
loginUseCase.execute(request);

//then
verify(memberFactory, times(1)).createNew(memberInfo);
verify(memberRepository, times(1)).save(member);
}

@Test
@DisplayName("로그인 이후 Jwt를 발급 및 저장한다.")
void loginSuccessCreateJwtAndSave() throws Exception {
//given
LoginRequest request = new LoginRequest("idToken", "KAKAO");
OauthMemberInfo memberInfo = new OauthMemberInfo("id", OauthServer.KAKAO, "[email protected]");
Member member = MemberFixture.createMember("memberId");
String access = "access";
String refresh = "refresh";
given(memberRepository.findByOauthIdAndOauthServer(memberInfo.getOauthId(), memberInfo.getOauthServer())).willReturn(Optional.of(member));
given(tokenValidatorManager.validate(request.idToken(), OauthServer.valueOf(request.oauthServer()))).willReturn(memberInfo);
given(jwtHandler.createAccessToken(member)).willReturn(access);
given(jwtHandler.createRefreshToken()).willReturn(refresh);

//when
LoginResponse result = loginUseCase.execute(request);

//then
assertThat(result.accessToken()).isEqualTo(access);
assertThat(result.refreshToken()).isEqualTo(refresh);
verify(redisCacheRepository, times(1)).saveToken(member.getId(), refresh);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.jabiseo.auth.dto;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("loginRequest 입력값 검증 테스트")
class LoginRequestTest {

private ValidatorFactory validatorFactory;
private Validator validator;

@BeforeEach
void init() {
validatorFactory = Validation.buildDefaultValidatorFactory();
validator = validatorFactory.getValidator();
}


@Test
@DisplayName("login 요청시 null값이 오면 예외를 반환한다.")
void nullRequestThrownException() {
//given
String idToken = null;
String oauthServer = null;
LoginRequest loginRequest = new LoginRequest(idToken, oauthServer);

//when
Set<ConstraintViolation<LoginRequest>> violations = validator.validate(loginRequest);

//then
assertThat(violations).isNotEmpty();
}

@DisplayName("login 요청시 idToken에 숫자가 오면 예외를 반환한다,")
InHyeok-J marked this conversation as resolved.
Show resolved Hide resolved
@ParameterizedTest
@ValueSource(strings = {"kakao", "kakakkao", "", "value", "ggogo", "KAKAOOO"})
void oauthServerNotAllowInputsThrowException(String oauthServer) {
String idToken = "IdTokens..";
LoginRequest loginRequest = new LoginRequest(idToken, oauthServer);

//when
Set<ConstraintViolation<LoginRequest>> violations = validator.validate(loginRequest);

//then
assertThat(violations).isNotEmpty();
}

@DisplayName("정상적인 입력 요청시 성공한다")
@ParameterizedTest
@ValueSource(strings = {"KAKAO", "GOOGLE"})
void LoginRequestSuccess(String oauthServer) {
//given
String idToken = "IdTokens..";
LoginRequest loginRequest = new LoginRequest(idToken, oauthServer);
//when

Set<ConstraintViolation<LoginRequest>> violations = validator.validate(loginRequest);
//then
assertThat(violations).isEmpty();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ public enum AuthenticationErrorCode implements ErrorCode {
EXPIRED_ID_TOKEN("만료된 idToken 입니다.", "AUTH_002", ErrorCode.UNAUTHORIZED),
NOT_SUPPORT_OAUTH("지원하지 않는 oauth 인증 수단입니다", "AUTH_003", ErrorCode.UNAUTHORIZED),
EXPIRED_APP_JWT("만료된 jwt 토큰 입니다", "AUTH_004", ErrorCode.UNAUTHORIZED),
INVALID_APP_JWT("잘못된 jwt 토큰입니다", "AUTH_005", ErrorCode.UNAUTHORIZED);
INVALID_APP_JWT("잘못된 jwt 토큰입니다", "AUTH_005", ErrorCode.UNAUTHORIZED),
GET_JWK_FAIL("jwk 획득 실패", "AUTH_006", ErrorCode.INTERNAL_SERVER_ERROR);

private final String message;
private final String errorCode;
Expand Down
Loading
Loading