diff --git a/jabiseo-api/build.gradle b/jabiseo-api/build.gradle index 8999266..4797eea 100644 --- a/jabiseo-api/build.gradle +++ b/jabiseo-api/build.gradle @@ -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' } diff --git a/jabiseo-api/src/main/java/com/jabiseo/JabiseoApiApplication.java b/jabiseo-api/src/main/java/com/jabiseo/JabiseoApiApplication.java index 482a2b7..6c8642b 100644 --- a/jabiseo-api/src/main/java/com/jabiseo/JabiseoApiApplication.java +++ b/jabiseo-api/src/main/java/com/jabiseo/JabiseoApiApplication.java @@ -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 { diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtHandler.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtHandler.java new file mode 100644 index 0000000..3c50564 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtHandler.java @@ -0,0 +1,111 @@ +package com.jabiseo.auth.application; + +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 payload = new HashMap<>(); + + return Jwts.builder() + .setSubject(member.getId().toString()) + .setIssuer(APP_ISSUER) + .setExpiration(Date.from(accessExpiredTime)) + .addClaims(payload) + .signWith(accessKey) + .compact(); + } + + 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(); + } +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtProperty.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtProperty.java new file mode 100644 index 0000000..bfa42b2 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/JwtProperty.java @@ -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; + } +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/MemberFactory.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/MemberFactory.java new file mode 100644 index 0000000..5b02855 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/MemberFactory.java @@ -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); + } +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/AbstractIdTokenValidator.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/AbstractIdTokenValidator.java new file mode 100644 index 0000000..1688da2 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/AbstractIdTokenValidator.java @@ -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 payload = idTokenJwtHandler.validateAndExtractPayload(idToken, oidcPublicKey, oidcIdTokenProperty.audience(), oidcIdTokenProperty.issuer()); + + return extractMemberInfoFromPayload(payload); + } + + abstract protected OidcPublicKey getOidcPublicKey(String kid); + + abstract protected OauthMemberInfo extractMemberInfoFromPayload(Map payload); + + abstract OauthServer getOauthServer(); +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/IdTokenJwtHandler.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/IdTokenJwtHandler.java new file mode 100644 index 0000000..b042bf1 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/IdTokenJwtHandler.java @@ -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 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 validateAndExtractPayload(String idToken, OidcPublicKey publicKey, String aud, String issuer) { + try { + Jws 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); + } +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/KakaoIdTokenValidator.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/KakaoIdTokenValidator.java new file mode 100644 index 0000000..93d3c12 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/KakaoIdTokenValidator.java @@ -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 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 getOidcPublicKeyByKakaoClient() { + try { + ResponseEntity 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 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; + } +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/OauthMemberInfo.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/OauthMemberInfo.java new file mode 100644 index 0000000..764b3ab --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/OauthMemberInfo.java @@ -0,0 +1,20 @@ +package com.jabiseo.auth.application.oidc; + +import com.jabiseo.member.domain.OauthServer; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class OauthMemberInfo { + + private final String oauthId; + private final OauthServer oauthServer; + private final String email; + + @Builder + public OauthMemberInfo(String oauthId, OauthServer oauthServer, String email) { + this.oauthId = oauthId; + this.oauthServer = oauthServer; + this.email = email; + } +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/TokenValidatorManager.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/TokenValidatorManager.java new file mode 100644 index 0000000..a1c33bc --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/TokenValidatorManager.java @@ -0,0 +1,31 @@ +package com.jabiseo.auth.application.oidc; + + +import com.jabiseo.auth.exception.AuthenticationBusinessException; +import com.jabiseo.auth.exception.AuthenticationErrorCode; +import com.jabiseo.member.domain.OauthServer; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@Component +public class TokenValidatorManager { + + private final Map validatorMap = new HashMap<>(); + + public TokenValidatorManager(Set idTokenValidators) { + idTokenValidators.forEach((v) -> validatorMap.put(v.getOauthServer(), v)); + } + + public OauthMemberInfo validate(String idToken, OauthServer oauthServer) { + AbstractIdTokenValidator idTokenValidator = validatorMap.get(oauthServer); + + if (idTokenValidator == null) { + throw new AuthenticationBusinessException(AuthenticationErrorCode.NOT_SUPPORT_OAUTH); + } + + return idTokenValidator.validate(idToken); + } +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/property/KakaoOidcProperty.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/property/KakaoOidcProperty.java new file mode 100644 index 0000000..4d72bcf --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/property/KakaoOidcProperty.java @@ -0,0 +1,24 @@ +package com.jabiseo.auth.application.oidc.property; + + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@ConfigurationProperties(prefix = "oidc.kakao") +public class KakaoOidcProperty { + + private final String clientId; + private final String adminKey; + private final String issuer; + + public KakaoOidcProperty(String clientId, String adminKey, String issuer) { + this.clientId = clientId; + this.adminKey = adminKey; + this.issuer = issuer; + } + + public OidcIdTokenProperty toIdTokenProperty() { + return new OidcIdTokenProperty(issuer, clientId); + } +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/property/OidcIdTokenProperty.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/property/OidcIdTokenProperty.java new file mode 100644 index 0000000..e78c907 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/oidc/property/OidcIdTokenProperty.java @@ -0,0 +1,13 @@ +package com.jabiseo.auth.application.oidc.property; + + +/** + * @param issuer 발행 회사 url + * @param audience client-id + */ +public record OidcIdTokenProperty( + String issuer, + String audience +) { + +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/LoginUseCase.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/LoginUseCase.java new file mode 100644 index 0000000..3af5900 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/LoginUseCase.java @@ -0,0 +1,46 @@ +package com.jabiseo.auth.application.usecase; + +import com.jabiseo.auth.application.MemberFactory; +import com.jabiseo.auth.dto.LoginRequest; +import com.jabiseo.auth.dto.LoginResponse; +import com.jabiseo.auth.application.JwtHandler; +import com.jabiseo.auth.application.oidc.OauthMemberInfo; +import com.jabiseo.auth.application.oidc.TokenValidatorManager; +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; + + +@Service +@Transactional +@RequiredArgsConstructor +public class LoginUseCase { + + private final TokenValidatorManager tokenValidatorManager; + private final MemberFactory memberFactory; + private final JwtHandler jwtHandler; + private final MemberRepository memberRepository; + private final RedisCacheRepository cacheRepository; + + public LoginResponse execute(LoginRequest loginRequest) { + OauthMemberInfo oauthMemberInfo = tokenValidatorManager.validate(loginRequest.idToken(), OauthServer.valueOf(loginRequest.oauthServer())); + + Member member = memberRepository.findByOauthIdAndOauthServer(oauthMemberInfo.getOauthId(), oauthMemberInfo.getOauthServer()) + .orElseGet(() -> { + Member newMember = memberFactory.createNew(oauthMemberInfo); + return memberRepository.save(newMember); + }); + + + String accessToken = jwtHandler.createAccessToken(member); + String refreshToken = jwtHandler.createRefreshToken(); + cacheRepository.saveToken(member.getId(), refreshToken); + + return new LoginResponse(accessToken, refreshToken); + } + +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/usecase/LogoutUseCase.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/LogoutUseCase.java similarity index 72% rename from jabiseo-api/src/main/java/com/jabiseo/auth/usecase/LogoutUseCase.java rename to jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/LogoutUseCase.java index d34446e..4008f47 100644 --- a/jabiseo-api/src/main/java/com/jabiseo/auth/usecase/LogoutUseCase.java +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/LogoutUseCase.java @@ -1,4 +1,4 @@ -package com.jabiseo.auth.usecase; +package com.jabiseo.auth.application.usecase; import org.springframework.stereotype.Service; diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/usecase/ReissueUseCase.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/ReissueUseCase.java similarity index 85% rename from jabiseo-api/src/main/java/com/jabiseo/auth/usecase/ReissueUseCase.java rename to jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/ReissueUseCase.java index 5f09fa9..06124c9 100644 --- a/jabiseo-api/src/main/java/com/jabiseo/auth/usecase/ReissueUseCase.java +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/ReissueUseCase.java @@ -1,4 +1,4 @@ -package com.jabiseo.auth.usecase; +package com.jabiseo.auth.application.usecase; import com.jabiseo.auth.dto.LoginResponse; import org.springframework.stereotype.Service; diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/usecase/WithdrawUseCase.java b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/WithdrawUseCase.java similarity index 73% rename from jabiseo-api/src/main/java/com/jabiseo/auth/usecase/WithdrawUseCase.java rename to jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/WithdrawUseCase.java index 7a25282..42517af 100644 --- a/jabiseo-api/src/main/java/com/jabiseo/auth/usecase/WithdrawUseCase.java +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/application/usecase/WithdrawUseCase.java @@ -1,4 +1,4 @@ -package com.jabiseo.auth.usecase; +package com.jabiseo.auth.application.usecase; import org.springframework.stereotype.Service; diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/controller/AuthController.java b/jabiseo-api/src/main/java/com/jabiseo/auth/controller/AuthController.java index 1164023..26f15ca 100644 --- a/jabiseo-api/src/main/java/com/jabiseo/auth/controller/AuthController.java +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/controller/AuthController.java @@ -1,13 +1,16 @@ package com.jabiseo.auth.controller; +import com.jabiseo.auth.dto.LoginRequest; import com.jabiseo.auth.dto.LoginResponse; -import com.jabiseo.auth.usecase.LoginUseCase; -import com.jabiseo.auth.usecase.LogoutUseCase; -import com.jabiseo.auth.usecase.ReissueUseCase; -import com.jabiseo.auth.usecase.WithdrawUseCase; +import com.jabiseo.auth.application.usecase.LoginUseCase; +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; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -25,8 +28,8 @@ public class AuthController { private final WithdrawUseCase withdrawUseCase; @PostMapping("/login") - public ResponseEntity login(String idToken) { - LoginResponse result = loginUseCase.execute(idToken); + public ResponseEntity login(@Valid @RequestBody LoginRequest loginRequest) { + LoginResponse result = loginUseCase.execute(loginRequest); return ResponseEntity.ok(result); } diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/dto/LoginRequest.java b/jabiseo-api/src/main/java/com/jabiseo/auth/dto/LoginRequest.java new file mode 100644 index 0000000..86d1d46 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/auth/dto/LoginRequest.java @@ -0,0 +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, + @EnumValid(enumClass = OauthServer.class, message = "oauthServer Type이 잘못됐습니다.") + String oauthServer) { +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/auth/usecase/LoginUseCase.java b/jabiseo-api/src/main/java/com/jabiseo/auth/usecase/LoginUseCase.java deleted file mode 100644 index 457f5fb..0000000 --- a/jabiseo-api/src/main/java/com/jabiseo/auth/usecase/LoginUseCase.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.jabiseo.auth.usecase; - -import com.jabiseo.auth.dto.LoginResponse; -import org.springframework.stereotype.Service; - -@Service -public class LoginUseCase { - - public LoginResponse execute(String idToken) { - return new LoginResponse("access_token", "refresh_token"); - } - -} diff --git a/jabiseo-api/src/main/java/com/jabiseo/common/EnumValid.java b/jabiseo-api/src/main/java/com/jabiseo/common/EnumValid.java new file mode 100644 index 0000000..f634ae2 --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/common/EnumValid.java @@ -0,0 +1,29 @@ +package com.jabiseo.common; + + + +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 }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint(validatedBy = ValueOfEnumValidator.class) +public @interface EnumValid { + Class> enumClass(); + + String message() default ""; + + Class[] groups() default {}; + + Class[] payload() default {}; + + boolean ignoreCase() default false; +} diff --git a/jabiseo-api/src/main/java/com/jabiseo/common/ValueOfEnumValidator.java b/jabiseo-api/src/main/java/com/jabiseo/common/ValueOfEnumValidator.java new file mode 100644 index 0000000..5182c9b --- /dev/null +++ b/jabiseo-api/src/main/java/com/jabiseo/common/ValueOfEnumValidator.java @@ -0,0 +1,33 @@ +package com.jabiseo.common; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValueOfEnumValidator implements ConstraintValidator { + + private EnumValid enumValid; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if(value == null){ + return false; + } + Enum[] enumValues = this.enumValid.enumClass().getEnumConstants(); + if (enumValues == null) { + return false; + } + for (Object enumValue : enumValues) { + if (value.equals(enumValue.toString()) + || this.enumValid.ignoreCase() && value.equalsIgnoreCase(enumValue.toString())) { + return true; + } + } + return false; + } + + @Override + public void initialize(EnumValid constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + this.enumValid = constraintAnnotation; + } +} diff --git a/jabiseo-api/src/main/resources/api.yml b/jabiseo-api/src/main/resources/api.yml new file mode 100644 index 0000000..e66821d --- /dev/null +++ b/jabiseo-api/src/main/resources/api.yml @@ -0,0 +1,30 @@ +#-- 해당 부분은 override 하지 않으면 그대로 적용이 된다. + +oidc: + kakao: + issuer: ${KAKAO_ISSUER} + admin-key: ${KAKAO_ADMIN_KEY} + client-id: ${KAKAO_CLIENT_ID} + +jwt: + access-expired-min: 60 + refresh-expired-day: 30 + refresh-key: ${REFRESH_KEY} + access-key: ${ACCESS_KEY} + +--- +spring: + config: + activate: + on-profile: api-local + +jwt: + access-expired-min: 6000 + +--- +spring: + config: + activate: + on-profile: api-dev + + diff --git a/jabiseo-api/src/main/resources/application.yml b/jabiseo-api/src/main/resources/application.yml index 93271fe..93e2360 100644 --- a/jabiseo-api/src/main/resources/application.yml +++ b/jabiseo-api/src/main/resources/application.yml @@ -2,7 +2,7 @@ # --- -> 구분자 없는 default 영역 spring: config: - import: "classpath:/domain.yml,classpath:/infra.yml" + import: "classpath:/domain.yml,classpath:/infra.yml,classpath:/api.yml" profiles: active: local # -- 기본 값은 local로 실행한다. @@ -10,7 +10,9 @@ spring: local: - "domain-local" - "infra-local" + - "api-local" dev: - "domain-dev" - "infra-dev" + - "api-dev" diff --git a/jabiseo-api/src/test/java/com/jabiseo/auth/application/oidc/KakaoIdTokenValidatorTest.java b/jabiseo-api/src/test/java/com/jabiseo/auth/application/oidc/KakaoIdTokenValidatorTest.java new file mode 100644 index 0000000..af6b374 --- /dev/null +++ b/jabiseo-api/src/test/java/com/jabiseo/auth/application/oidc/KakaoIdTokenValidatorTest.java @@ -0,0 +1,131 @@ +package com.jabiseo.auth.application.oidc; + +import com.jabiseo.auth.application.oidc.property.KakaoOidcProperty; +import com.jabiseo.auth.exception.AuthenticationBusinessException; +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; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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("카카오IdToken검증 테스트") +@ExtendWith(MockitoExtension.class) +class KakaoIdTokenValidatorTest { + + @InjectMocks + KakaoIdTokenValidator validator; + + @Mock + KakaoOidcProperty kakaoOidcProperty; + + @Mock + IdTokenJwtHandler idTokenJwtHandler; + + @Mock + KakaoKauthClient kakaoKauthClient; + + @Mock + RedisCacheRepository redisCacheRepository; + + + @BeforeEach + void setUp() { + kakaoOidcProperty = new KakaoOidcProperty("client id", "adminKey", "issuer"); + } + + @Test + @DisplayName("카카오 oidc public key 조회시 캐시에 데이터가 있다면 카카오API를 호출하지 않는다. ") + void notCallApiAlreadySavedCache() { + //given + given(redisCacheRepository.getPublicKeys(any())).willReturn(List.of(mockPublicKey("kid1"), mockPublicKey("kid2"))); + + //when + validator.getOidcPublicKey("kid1"); + + //then + verify(kakaoKauthClient, times(0)).getPublicKeys(); + } + + @Test + @DisplayName("카카오 oidc public key 조회시 캐시에 데이터가 없다면 카카오API를 호출하고 저장한다") + void callApiNotSavedCache() { + //given + List publicKeys = List.of(mockPublicKey("kid1"), mockPublicKey("kid2")); + ResponseEntity entity = ResponseEntity.of(Optional.of(new OidcPublicKeyResponse(publicKeys))); + given(redisCacheRepository.getPublicKeys(any())).willReturn(null); + given(kakaoKauthClient.getPublicKeys()).willReturn(entity); + + //when + validator.getOidcPublicKey("kid1"); + + //then + verify(redisCacheRepository, times(1)).savePublicKey(any(), any()); + } + + @Test + @DisplayName("카카오 oidc public key조회시 kid에 맞는 key가 없다면 예외를 반환한다.") + void notMatchKidThrownException() { + //given + List publicKeys = List.of(mockPublicKey("kid1"), mockPublicKey("kid2")); + given(redisCacheRepository.getPublicKeys(any())).willReturn(publicKeys); + String notMatchKid = "kidkid"; + + //when + assertThatThrownBy(() -> validator.getOidcPublicKey(notMatchKid)) + .isInstanceOf(AuthenticationBusinessException.class) + .hasMessage(AuthenticationErrorCode.INVALID_ID_TOKEN.getMessage()); + } + + @Test + @DisplayName("카카오 oidc public key조회시 kid에 맞는 key가 있다면 해당 Public Key를 리턴한다.") + void SuccessMatchKidReturnOidcPublicKey() { + //given + String matchKid = "kid0"; + OidcPublicKey matchPublicKey = mockPublicKey(matchKid); + given(redisCacheRepository.getPublicKeys(any())).willReturn(List.of(mockPublicKey("kid1"),matchPublicKey)); + + //when + OidcPublicKey oidcPublicKey = validator.getOidcPublicKey(matchKid); + + //then + 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"); + } +} diff --git a/jabiseo-api/src/test/java/com/jabiseo/auth/application/usecase/LoginUseCaseTest.java b/jabiseo-api/src/test/java/com/jabiseo/auth/application/usecase/LoginUseCaseTest.java new file mode 100644 index 0000000..9d3562a --- /dev/null +++ b/jabiseo-api/src/test/java/com/jabiseo/auth/application/usecase/LoginUseCaseTest.java @@ -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 회원일시 맴버 객체를 생성하고 저장한다.") + void firstOauthUserIsSignUpAndSave() { + //given + LoginRequest request = new LoginRequest("idToken", "KAKAO"); + OauthMemberInfo memberInfo = new OauthMemberInfo("id", OauthServer.KAKAO, "email@emil.com"); + 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@emil.com"); + 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); + } +} \ No newline at end of file diff --git a/jabiseo-api/src/test/java/com/jabiseo/auth/dto/LoginRequestTest.java b/jabiseo-api/src/test/java/com/jabiseo/auth/dto/LoginRequestTest.java new file mode 100644 index 0000000..9bf5c52 --- /dev/null +++ b/jabiseo-api/src/test/java/com/jabiseo/auth/dto/LoginRequestTest.java @@ -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> violations = validator.validate(loginRequest); + + //then + assertThat(violations).isNotEmpty(); + } + + @DisplayName("login 요청시 oauthServer에 정확한 값이 오지 않으면 예외를 반환한다,") + @ParameterizedTest + @ValueSource(strings = {"kakao", "kakakkao", "", "value", "ggogo", "KAKAOOO"}) + void oauthServerNotAllowInputsThrowException(String oauthServer) { + String idToken = "IdTokens.."; + LoginRequest loginRequest = new LoginRequest(idToken, oauthServer); + + //when + Set> 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> violations = validator.validate(loginRequest); + //then + assertThat(violations).isEmpty(); + } + +} \ No newline at end of file diff --git a/jabiseo-api/src/test/java/com/jabiseo/fixture/MemberFixture.java b/jabiseo-api/src/test/java/com/jabiseo/fixture/MemberFixture.java index 720987b..3025a98 100644 --- a/jabiseo-api/src/test/java/com/jabiseo/fixture/MemberFixture.java +++ b/jabiseo-api/src/test/java/com/jabiseo/fixture/MemberFixture.java @@ -1,12 +1,13 @@ package com.jabiseo.fixture; import com.jabiseo.member.domain.Member; +import com.jabiseo.member.domain.OauthServer; public class MemberFixture { public static Member createMember(String memberId) { return Member.of(memberId, "email", "name", - "oauth2Id", "profile", "profileImage"); + "oauth2Id", OauthServer.KAKAO, "profileImage"); } } diff --git a/jabiseo-common/src/main/java/com/jabiseo/exception/CommonErrorCode.java b/jabiseo-common/src/main/java/com/jabiseo/exception/CommonErrorCode.java index 50fb638..c4b1b9a 100644 --- a/jabiseo-common/src/main/java/com/jabiseo/exception/CommonErrorCode.java +++ b/jabiseo-common/src/main/java/com/jabiseo/exception/CommonErrorCode.java @@ -3,10 +3,10 @@ import lombok.Getter; @Getter -public enum CommonErrorCode implements ErrorCode{ +public enum CommonErrorCode implements ErrorCode { INVALID_REQUEST_BODY("Invalid request body", "COM_001", ErrorCode.BAD_REQUEST), - ; + INTERNAL_SERVER_ERROR(" 서버 에러", "COM_002", ErrorCode.INTERNAL_SERVER_ERROR); private final String message; private final String errorCode; diff --git a/jabiseo-common/src/main/java/com/jabiseo/exception/ErrorCode.java b/jabiseo-common/src/main/java/com/jabiseo/exception/ErrorCode.java index 31ca1f8..93744cd 100644 --- a/jabiseo-common/src/main/java/com/jabiseo/exception/ErrorCode.java +++ b/jabiseo-common/src/main/java/com/jabiseo/exception/ErrorCode.java @@ -6,6 +6,10 @@ public interface ErrorCode { int BAD_REQUEST = 400; + int UNAUTHORIZED = 401; + + int INTERNAL_SERVER_ERROR = 500; + String getMessage(); String getErrorCode(); diff --git a/jabiseo-domain/src/main/java/com/jabiseo/auth/exception/AuthenticationBusinessException.java b/jabiseo-domain/src/main/java/com/jabiseo/auth/exception/AuthenticationBusinessException.java new file mode 100644 index 0000000..65411ff --- /dev/null +++ b/jabiseo-domain/src/main/java/com/jabiseo/auth/exception/AuthenticationBusinessException.java @@ -0,0 +1,11 @@ +package com.jabiseo.auth.exception; + +import com.jabiseo.exception.BusinessException; +import com.jabiseo.exception.ErrorCode; + +public class AuthenticationBusinessException extends BusinessException { + + public AuthenticationBusinessException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/jabiseo-domain/src/main/java/com/jabiseo/auth/exception/AuthenticationErrorCode.java b/jabiseo-domain/src/main/java/com/jabiseo/auth/exception/AuthenticationErrorCode.java new file mode 100644 index 0000000..e95bc01 --- /dev/null +++ b/jabiseo-domain/src/main/java/com/jabiseo/auth/exception/AuthenticationErrorCode.java @@ -0,0 +1,39 @@ +package com.jabiseo.auth.exception; + +import com.jabiseo.exception.ErrorCode; + +public enum AuthenticationErrorCode implements ErrorCode { + + INVALID_ID_TOKEN("잘못된 idToken 입니다.", "AUTH_001", ErrorCode.UNAUTHORIZED), + 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), + GET_JWK_FAIL("jwk 획득 실패", "AUTH_006", ErrorCode.INTERNAL_SERVER_ERROR); + + private final String message; + private final String errorCode; + private final int statusCode; + + AuthenticationErrorCode(String message, String errorCode, int statusCode) { + this.message = message; + this.errorCode = errorCode; + this.statusCode = statusCode; + } + + + @Override + public String getMessage() { + return this.message; + } + + @Override + public String getErrorCode() { + return this.errorCode; + } + + @Override + public int getStatusCode() { + return this.statusCode; + } +} diff --git a/jabiseo-domain/src/main/java/com/jabiseo/member/domain/Member.java b/jabiseo-domain/src/main/java/com/jabiseo/member/domain/Member.java index f26b133..7610db5 100644 --- a/jabiseo-domain/src/main/java/com/jabiseo/member/domain/Member.java +++ b/jabiseo-domain/src/main/java/com/jabiseo/member/domain/Member.java @@ -36,7 +36,8 @@ public class Member { private String oauthId; - private String oauthServer; + @Enumerated(EnumType.STRING) + private OauthServer oauthServer; private boolean deleted = false; @@ -57,7 +58,7 @@ public class Member { @OneToMany(mappedBy = "member") private List bookmarks = new ArrayList<>(); - private Member(String id, String email, String nickname, String oauthId, String oauthServer, String profileImage) { + private Member(String id, String email, String nickname, String oauthId, OauthServer oauthServer, String profileImage) { this.id = id; this.email = email; this.nickname = nickname; @@ -67,7 +68,7 @@ private Member(String id, String email, String nickname, String oauthId, String } public static Member of(String id, String email, String nickname, - String oauthId, String oauthServer, String profileImage) { + String oauthId, OauthServer oauthServer, String profileImage) { return new Member(id, email, nickname, oauthId, oauthServer, profileImage); } diff --git a/jabiseo-domain/src/main/java/com/jabiseo/member/domain/MemberRepository.java b/jabiseo-domain/src/main/java/com/jabiseo/member/domain/MemberRepository.java index a3d0d24..f6f055a 100644 --- a/jabiseo-domain/src/main/java/com/jabiseo/member/domain/MemberRepository.java +++ b/jabiseo-domain/src/main/java/com/jabiseo/member/domain/MemberRepository.java @@ -2,5 +2,9 @@ import org.springframework.data.jpa.repository.JpaRepository; -public interface MemberRepository extends JpaRepository{ +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + Optional findByOauthIdAndOauthServer(String oauthId, OauthServer oauthServer); } diff --git a/jabiseo-domain/src/main/java/com/jabiseo/member/domain/OauthServer.java b/jabiseo-domain/src/main/java/com/jabiseo/member/domain/OauthServer.java new file mode 100644 index 0000000..361aacb --- /dev/null +++ b/jabiseo-domain/src/main/java/com/jabiseo/member/domain/OauthServer.java @@ -0,0 +1,5 @@ +package com.jabiseo.member.domain; + +public enum OauthServer { + KAKAO, GOOGLE +} diff --git a/jabiseo-domain/src/main/java/com/jabiseo/member/domain/RandomNicknameGenerator.java b/jabiseo-domain/src/main/java/com/jabiseo/member/domain/RandomNicknameGenerator.java new file mode 100644 index 0000000..32b0165 --- /dev/null +++ b/jabiseo-domain/src/main/java/com/jabiseo/member/domain/RandomNicknameGenerator.java @@ -0,0 +1,35 @@ +package com.jabiseo.member.domain; + +import org.springframework.stereotype.Component; + +import java.util.Random; + +@Component +public class RandomNicknameGenerator { + + private static final String[] prefixStrings = {"자격증마스터", "공부의왕", "백점만이살길", "하루공부", "재밌는자격증"}; + + public String generate() { + Random random = new Random(); + int prefixIndex = random.nextInt(prefixStrings.length); + String prefix = prefixStrings[prefixIndex]; + String suffixString = generateRandomNumber(); + + return prefix + suffixString; + } + + private static String generateRandomNumber() { + Random random = new Random(); + String randomNumber; + do { + int number = random.nextInt(9000) + 1000; // 1000부터 9999 사이의 난수 생성 + randomNumber = String.valueOf(number); + } while (!isValid(randomNumber)); + + return randomNumber; + } + + private static boolean isValid(String number) { + return number.charAt(0) != '0'; + } +} diff --git a/jabiseo-domain/src/test/java/com/jabiseo/member/domain/RandomNicknameGeneratorTest.java b/jabiseo-domain/src/test/java/com/jabiseo/member/domain/RandomNicknameGeneratorTest.java new file mode 100644 index 0000000..94940fb --- /dev/null +++ b/jabiseo-domain/src/test/java/com/jabiseo/member/domain/RandomNicknameGeneratorTest.java @@ -0,0 +1,30 @@ +package com.jabiseo.member.domain; + +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.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class RandomNicknameGeneratorTest { + + @InjectMocks + RandomNicknameGenerator generator; + + + @Test + @DisplayName("랜덤 닉네임 생성 시 문자열 뒤 숫자 4자리의 난수가 생성된다.") + void randomNicknameSuccess(){ + //given + //when + + String generate = generator.generate(); + String isSuffix = generate.substring(generate.length()-4); + //then + assertThat(isSuffix).matches("\\d{4}"); + } +} \ No newline at end of file diff --git a/jabiseo-infrastructure/build.gradle b/jabiseo-infrastructure/build.gradle index 89575f5..c693b6a 100644 --- a/jabiseo-infrastructure/build.gradle +++ b/jabiseo-infrastructure/build.gradle @@ -10,6 +10,7 @@ jar { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-web' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/jabiseo-infrastructure/src/main/java/com/jabiseo/cache/RedisCacheRepository.java b/jabiseo-infrastructure/src/main/java/com/jabiseo/cache/RedisCacheRepository.java new file mode 100644 index 0000000..8fc0132 --- /dev/null +++ b/jabiseo-infrastructure/src/main/java/com/jabiseo/cache/RedisCacheRepository.java @@ -0,0 +1,58 @@ +package com.jabiseo.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jabiseo.client.OidcPublicKey; +import com.jabiseo.exception.BusinessException; +import com.jabiseo.exception.CommonErrorCode; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Component +public class RedisCacheRepository { + + private final RedisTemplate redisStringTemplate; + private final ValueOperations operation; + private final ObjectMapper mapper = new ObjectMapper(); + + public RedisCacheRepository(RedisTemplate redisStringTemplate) { + this.redisStringTemplate = redisStringTemplate; + this.operation = redisStringTemplate.opsForValue(); + } + + + public void saveToken(String key, String value) { + operation.set(key, value); + } + + + public void savePublicKey(String key, List publicKeys) { + try { + String publicKeyString = mapper.writeValueAsString(publicKeys); + + // TODO: timeout 값 논의 필요 + operation.set(key, publicKeyString, 1, TimeUnit.DAYS); + } catch (JsonProcessingException e) { + throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + + public List getPublicKeys(String key) { + String values = operation.get(key); + if (values == null) { + return null; + } + try { + return Arrays.asList(mapper.readValue(values, OidcPublicKey[].class)); + } catch (JsonProcessingException e) { + throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + + +} diff --git a/jabiseo-infrastructure/src/main/java/com/jabiseo/client/KakaoKauthClient.java b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/KakaoKauthClient.java new file mode 100644 index 0000000..5617bff --- /dev/null +++ b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/KakaoKauthClient.java @@ -0,0 +1,19 @@ +package com.jabiseo.client; + +; +import org.springframework.http.ResponseEntity; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + + + +@HttpExchange +public interface KakaoKauthClient { + + /* + * ref: https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#oidc-find-public-key + * */ + @GetExchange(url = "/.well-known/jwks.json") + ResponseEntity getPublicKeys(); + +} diff --git a/jabiseo-infrastructure/src/main/java/com/jabiseo/client/NetworkApiErrorCode.java b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/NetworkApiErrorCode.java new file mode 100644 index 0000000..a6cf63a --- /dev/null +++ b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/NetworkApiErrorCode.java @@ -0,0 +1,32 @@ +package com.jabiseo.client; + +import com.jabiseo.exception.ErrorCode; + +public enum NetworkApiErrorCode implements ErrorCode { + KAKAO_JWK_API_FAIL("카카오 kauth jwk 연결 실패", "NETWORK_001", ErrorCode.INTERNAL_SERVER_ERROR); + + private final String message; + private final String errorCode; + private final int statusCode; + + NetworkApiErrorCode(String message, String errorCode, int statusCode) { + this.message = message; + this.errorCode = errorCode; + this.statusCode = statusCode; + } + + @Override + public String getMessage() { + return ""; + } + + @Override + public String getErrorCode() { + return ""; + } + + @Override + public int getStatusCode() { + return 0; + } +} diff --git a/jabiseo-infrastructure/src/main/java/com/jabiseo/client/NetworkApiException.java b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/NetworkApiException.java new file mode 100644 index 0000000..8e69a6a --- /dev/null +++ b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/NetworkApiException.java @@ -0,0 +1,11 @@ +package com.jabiseo.client; + +import com.jabiseo.exception.BusinessException; +import com.jabiseo.exception.ErrorCode; + +public class NetworkApiException extends BusinessException { + + public NetworkApiException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/jabiseo-infrastructure/src/main/java/com/jabiseo/client/OidcPublicKey.java b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/OidcPublicKey.java new file mode 100644 index 0000000..d35e032 --- /dev/null +++ b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/OidcPublicKey.java @@ -0,0 +1,34 @@ +package com.jabiseo.client; + +import lombok.Getter; +import lombok.ToString; + +import java.util.Objects; + +@ToString +@Getter +public class OidcPublicKey { + + private String kid; + private String alg; + private String use; + private String n; + private String e; + + public OidcPublicKey(String kid, String alg, String use, String n, String e) { + this.kid = kid; + this.alg = alg; + this.use = use; + this.n = n; + this.e = e; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OidcPublicKey that = (OidcPublicKey) o; + return Objects.equals(kid, that.kid); + } + +} diff --git a/jabiseo-infrastructure/src/main/java/com/jabiseo/client/OidcPublicKeyResponse.java b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/OidcPublicKeyResponse.java new file mode 100644 index 0000000..c1a3579 --- /dev/null +++ b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/OidcPublicKeyResponse.java @@ -0,0 +1,19 @@ +package com.jabiseo.client; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class OidcPublicKeyResponse { + + private List keys; + + public OidcPublicKeyResponse(List keys) { + this.keys = keys; + } +} diff --git a/jabiseo-infrastructure/src/main/java/com/jabiseo/client/RestClientConfig.java b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/RestClientConfig.java new file mode 100644 index 0000000..aeb54af --- /dev/null +++ b/jabiseo-infrastructure/src/main/java/com/jabiseo/client/RestClientConfig.java @@ -0,0 +1,32 @@ +package com.jabiseo.client; + + +import com.jabiseo.exception.CommonErrorCode; +import com.jabiseo.exception.ErrorCode; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class RestClientConfig { + + + @Bean + public KakaoKauthClient kakaoKauthClient() { + RestClient client = RestClient.builder() + .baseUrl("https://kauth.kakao.com") + .defaultStatusHandler(HttpStatusCode::isError, ((request, response) -> { + throw new NetworkApiException(NetworkApiErrorCode.KAKAO_JWK_API_FAIL); + })) + .build(); + + return HttpServiceProxyFactory + .builder() + .exchangeAdapter(RestClientAdapter.create(client)) + .build() + .createClient(KakaoKauthClient.class); + } +} diff --git a/jabiseo-infrastructure/src/main/resources/infra.yml b/jabiseo-infrastructure/src/main/resources/infra.yml index 3e51736..97c41a4 100644 --- a/jabiseo-infrastructure/src/main/resources/infra.yml +++ b/jabiseo-infrastructure/src/main/resources/infra.yml @@ -6,6 +6,12 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + + --- spring: config: