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 22 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
5 changes: 5 additions & 0 deletions jabiseo-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ dependencies {
implementation project(":jabiseo-domain")
implementation project(":jabiseo-infrastructure")
implementation project(":jabiseo-common")


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
Expand Up @@ -3,7 +3,9 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@ConfigurationPropertiesScan
@SpringBootApplication
public class JabiseoApiApplication {

Expand Down
111 changes: 111 additions & 0 deletions jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.jabiseo.auth.application;
InHyeok-J marked this conversation as resolved.
Show resolved Hide resolved

import com.jabiseo.auth.exception.AuthenticationBusinessException;
import com.jabiseo.auth.exception.AuthenticationErrorCode;
import com.jabiseo.member.domain.Member;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;

@Component
public class JwtHandler {

private final Key accessKey;
private final Key refreshKey;
private final Integer accessExpiredMin;
private final Integer refreshExpiredDay;
private final String APP_ISSUER = "jabiseo";

public JwtHandler(JwtProperty jwtProperty) {
byte[] accessEncodeByte = Base64.getEncoder().encode((jwtProperty.getAccessKey().getBytes()));
byte[] refreshEncodeByte = Base64.getEncoder().encode(jwtProperty.getRefreshKey().getBytes());
this.accessExpiredMin = jwtProperty.getAccessExpiredMin();
this.refreshExpiredDay = jwtProperty.getRefreshExpiredDay();
this.accessKey = Keys.hmacShaKeyFor(accessEncodeByte);
this.refreshKey = Keys.hmacShaKeyFor(refreshEncodeByte);
}


public String createAccessToken(Member member) {
Instant accessExpiredTime = Instant.now()
.plus(this.accessExpiredMin, ChronoUnit.MINUTES);

Map<String, Object> payload = new HashMap<>();

return Jwts.builder()
.setSubject(member.getId().toString())
.setIssuer(APP_ISSUER)
.setExpiration(Date.from(accessExpiredTime))
.addClaims(payload)
.signWith(accessKey)
.compact();
InHyeok-J marked this conversation as resolved.
Show resolved Hide resolved
}

public String createRefreshToken() {
Instant refreshExpiredTime = Instant.now()
.plus(this.refreshExpiredDay, ChronoUnit.DAYS);
return Jwts.builder()
.setExpiration(Date.from(refreshExpiredTime))
.signWith(refreshKey)
.compact();
}


public boolean validateAccessToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(accessKey)
.build()
.parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
throw new AuthenticationBusinessException(AuthenticationErrorCode.EXPIRED_APP_JWT);
} catch (Exception e) {
throw new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_APP_JWT);
}
}

public void validateRefreshToken(String refreshToken) {
try {
Jwts.parserBuilder()
.setSigningKey(refreshKey)
.build()
.parseClaimsJws(refreshToken);
} catch (ExpiredJwtException e) {
throw new AuthenticationBusinessException(AuthenticationErrorCode.EXPIRED_APP_JWT);
} catch (Exception e) {
throw new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_APP_JWT);
}
}


public Claims getClaimFromExpiredAccessToken(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(accessKey)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
} catch (Exception e) {
throw new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_APP_JWT);
}
}

public Claims getClaimsFromAccessToken(String token) {
return Jwts
.parserBuilder()
.setSigningKey(accessKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.jabiseo.auth.application;


import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Getter
@ConfigurationProperties(prefix = "jwt")
public class JwtProperty {

private final String accessKey;
private final String refreshKey;
private final Integer accessExpiredMin;
private final Integer refreshExpiredDay;

public JwtProperty(String accessKey, String refreshKey, Integer accessExpiredMin, Integer refreshExpiredDay) {
this.accessKey = accessKey;
this.refreshKey = refreshKey;
this.accessExpiredMin = accessExpiredMin;
this.refreshExpiredDay = refreshExpiredDay;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.jabiseo.auth.application;


import com.jabiseo.auth.application.oidc.OauthMemberInfo;
import com.jabiseo.member.domain.Member;
import com.jabiseo.member.domain.OauthServer;
import com.jabiseo.member.domain.RandomNicknameGenerator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
@RequiredArgsConstructor
public class MemberFactory {

private final RandomNicknameGenerator randomNicknameGenerator;

// TODO: S3 + CDN 생성 후 변경해야 한다.
private final String DEFAULT_IMAGE_URL = "https://github.com/Jabiseo/Jabiseo-Backend/assets/28949213/fb6cb510-05fa-4791-a9c1-74a379294936";

public Member createNew(OauthMemberInfo oauthMemberInfo) {
String nickname = randomNicknameGenerator.generate();

// TODO: ID 생성 전략을 통해 따로 생성해야 한다.
String id = UUID.randomUUID().toString();
return Member.of(id, oauthMemberInfo.getEmail(), nickname, oauthMemberInfo.getOauthId(), oauthMemberInfo.getOauthServer(), DEFAULT_IMAGE_URL);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.jabiseo.auth.application.oidc;


import com.jabiseo.client.OidcPublicKey;
import com.jabiseo.auth.application.oidc.property.OidcIdTokenProperty;
import com.jabiseo.member.domain.OauthServer;

import java.util.Map;

public abstract class AbstractIdTokenValidator {

private final OidcIdTokenProperty oidcIdTokenProperty;
private final IdTokenJwtHandler idTokenJwtHandler;

public AbstractIdTokenValidator(OidcIdTokenProperty oidcIdTokenProperty, IdTokenJwtHandler idTokenJwtHandler) {
this.oidcIdTokenProperty = oidcIdTokenProperty;
this.idTokenJwtHandler = idTokenJwtHandler;
}

/*
* 1. idToken Header 에서 kid라는 key 를 찾고 kid에 맞는 public key를 가져온다.
* 2. 우리 앱의 issuer, client-id가 jwt token안에 있는지 확인/ signature 에 대한 검증을 진행한다.
* 3. payload에서 원하는 값을 추출해 return.
*/

public OauthMemberInfo validate(String idToken) {
String kid = idTokenJwtHandler.findKidFromHeader(idToken);

OidcPublicKey oidcPublicKey = getOidcPublicKey(kid);
Map<String, Object> payload = idTokenJwtHandler.validateAndExtractPayload(idToken, oidcPublicKey, oidcIdTokenProperty.audience(), oidcIdTokenProperty.issuer());

return extractMemberInfoFromPayload(payload);
}

abstract protected OidcPublicKey getOidcPublicKey(String kid);

abstract protected OauthMemberInfo extractMemberInfoFromPayload(Map<String, Object> payload);

abstract OauthServer getOauthServer();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.jabiseo.auth.application.oidc;

import com.jabiseo.client.OidcPublicKey;
import com.jabiseo.auth.exception.AuthenticationBusinessException;
import com.jabiseo.auth.exception.AuthenticationErrorCode;
import io.jsonwebtoken.*;
import org.springframework.stereotype.Component;

import java.math.BigInteger;
import java.security.Key;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

@Component
public class IdTokenJwtHandler {


public String findKidFromHeader(String jwt) {
try {
Jwt<Header, Claims> headerClaimsJwt = Jwts.parserBuilder()
.build()
.parseClaimsJwt(parseToken(jwt));
Header header = headerClaimsJwt.getHeader();
Object kid = header.get("kid");
if (kid == null) {
throw new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_ID_TOKEN);
}

return (String) kid;
} catch (ExpiredJwtException e) {
throw new AuthenticationBusinessException(AuthenticationErrorCode.EXPIRED_ID_TOKEN);
} catch (Exception e) {
throw new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_ID_TOKEN);
}
}

public Map<String, Object> validateAndExtractPayload(String idToken, OidcPublicKey publicKey, String aud, String issuer) {
try {
Jws<Claims> claimsJws = Jwts.parserBuilder()
.requireAudience(aud)
.requireIssuer(issuer)
.setSigningKey(getRSAPublicKey(publicKey.getN(), publicKey.getE()))
.build()
.parseClaimsJws(idToken);

Claims body = claimsJws.getBody();
return new HashMap<>(body);
} catch (Exception e) {
throw new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_ID_TOKEN);
}
}

private String parseToken(String token) {
String[] splitToken = token.split("\\.");
if (splitToken.length != 3) {
throw new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_ID_TOKEN);
}
return splitToken[0] + "." + splitToken[1] + ".";
}

private Key getRSAPublicKey(String modulus, String exponent)
throws InvalidKeySpecException, NoSuchAlgorithmException {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
byte[] decodeN = Base64.getUrlDecoder()
.decode(modulus);
byte[] decodeE = Base64.getUrlDecoder()
.decode(exponent);
BigInteger n = new BigInteger(1, decodeN);
BigInteger e = new BigInteger(1, decodeE);

RSAPublicKeySpec keySpec = new RSAPublicKeySpec(n, e);
return keyFactory.generatePublic(keySpec);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.jabiseo.auth.application.oidc;

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 {

private static final String KAKAO_ID_KEY = "sub";
private static final String KAKAO_EMAIL_KEY = "email";
private final KakaoKauthClient kakaoKauthClient;
private final RedisCacheRepository redisCacheRepository;
private static final String CACHE_KEY = "KAKAO_OIDC_PUBLIC_KEY";

public KakaoIdTokenValidator(KakaoOidcProperty kakaoOidcProperty, IdTokenJwtHandler idTokenJwtHandler, KakaoKauthClient kakaoKauthClient, RedisCacheRepository redisCacheRepository) {
super(kakaoOidcProperty.toIdTokenProperty(), idTokenJwtHandler);
this.kakaoKauthClient = kakaoKauthClient;
this.redisCacheRepository = redisCacheRepository;
}

@Override
protected OidcPublicKey getOidcPublicKey(String kid) {

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

return keys.stream().filter((key) -> key.getKid().equals(kid))
.findAny()
.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);
String email = (String) payload.get(KAKAO_EMAIL_KEY);
if (requireValueIsNull(oauthId, email)) {
throw new AuthenticationBusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR);
}

return OauthMemberInfo.builder()
.oauthId(oauthId)
.oauthServer(OauthServer.KAKAO)
.email(email)
.build();
}

@Override
OauthServer getOauthServer() {
return OauthServer.KAKAO;
}

/*
* 해당 예외가 발생하는건 카카오에서 프로퍼티 key 값을 바꾸지 않는 이상은 발생하지 않는다.
*/
private boolean requireValueIsNull(String oauthId, String email) {
return oauthId == null || email == null;
}
}
Loading
Loading