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 - 애플 로그인 기능 구현 #25

Merged
merged 19 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
736f159
feat - #24 애플 feign client 구현
kseysh Jan 9, 2024
4d68aff
deploy - #24 배포 스크립트 프로필 prod로 설정
kseysh Jan 9, 2024
d3a0f4b
feat - #24 애플 공개키 response dto 구현
kseysh Jan 9, 2024
a757478
feat - #24 애플 공개키 generator 구현
kseysh Jan 9, 2024
901827d
feat - #24 애플 identity token parser 구현
kseysh Jan 9, 2024
f85f95c
feat - #24 애플 identity token validator 구현
kseysh Jan 9, 2024
ade4b20
feat - #24 social id String으로 변환
kseysh Jan 9, 2024
e8b7f98
feat - #24 애플 oauth provider 구현
kseysh Jan 9, 2024
0a29b40
feat - #24 회원가입 request에 name 필드 추가
kseysh Jan 9, 2024
a2941c9
chore - #24 컨벤션에 맞게 공백 삭제
kseysh Jan 10, 2024
44a7da2
refactor - #24 매개변수의 불변성 보장을 위해 final 추가
kseysh Jan 10, 2024
ff8ad1f
refactor - #24 Builder를 생성자에서 사용하도록 변경
kseysh Jan 10, 2024
027df6e
refactor - #24 불필요한 GetMapping 옵션 제거
kseysh Jan 10, 2024
c8bd3af
merge - #24 merge전 develop branch와 충돌해결
kseysh Jan 10, 2024
a1e2ffa
chore - #24 컨벤션에 맞도록 공백 수정
kseysh Jan 10, 2024
635321d
chore - #24 회원가입 요청 status code 403으로 변경
kseysh Jan 10, 2024
93d2e39
refactor - #24 매직넘버 enum으로 변경
kseysh Jan 10, 2024
109c86c
refactor - #24 회원가입 이후 status code 201 CREATED로 변경
kseysh Jan 10, 2024
61fcfe5
refactor - #24 Util 클래스 네이밍을 책임을 명확히 하도록 UserIdConvertor로 변경
kseysh Jan 10, 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
2 changes: 1 addition & 1 deletion script/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ JAR_PID=$(pgrep -f $JAR_NAME)
# shellcheck disable=SC2153
# shellcheck disable=SC2024
source ~/.bashrc
sudo nohup java -jar "$JAR_PATH" >nohup.out 2>&1 </dev/null &
sudo nohup java -Dspring.profiles.active=prod -jar "$JAR_PATH" >nohup.out 2>&1 </dev/null &
2 changes: 1 addition & 1 deletion src/main/java/sopt/org/HMH/domain/user/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public class User extends BaseTimeEntity {
private SocialPlatform socialPlatform;

@Column(name = "social_id")
private Long socialId;
private String socialId;

@Column(name = "point")
@Builder.Default
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
public record SocialSignUpRequest(

SocialPlatform socialPlatform,
String name,
@JsonProperty(value = "onboarding")
OnboardingRequest onboardingRequest,
@JsonProperty(value = "challenge")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

public interface UserRepository extends JpaRepository<User, Long> {

default User findBySocialPlatformAndSocialIdOrThrowException(SocialPlatform socialPlatform, Long socialId) {
default User findBySocialPlatformAndSocialIdOrThrowException(SocialPlatform socialPlatform, String socialId) {
return findBySocialPlatformAndSocialId(socialPlatform, socialId).orElseThrow(() -> new UserException(
UserError.NOT_SIGNUP_USER));
}
Expand All @@ -19,6 +19,6 @@ default User findByIdOrThrowException(Long userId) {
UserError.NOT_FOUND_USER));
}

Optional<User> findBySocialPlatformAndSocialId(SocialPlatform socialPlatform, Long socialId);
boolean existsBySocialPlatformAndSocialId(SocialPlatform socialPlatform, Long socialId);
Optional<User> findBySocialPlatformAndSocialId(SocialPlatform socialPlatform, String socialId);
boolean existsBySocialPlatformAndSocialId(SocialPlatform socialPlatform, String socialId);
}
26 changes: 15 additions & 11 deletions src/main/java/sopt/org/HMH/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import sopt.org.HMH.global.auth.jwt.exception.JwtException;
import sopt.org.HMH.global.auth.security.UserAuthentication;
import sopt.org.HMH.global.auth.social.SocialPlatform;
import sopt.org.HMH.global.auth.social.apple.fegin.AppleOAuthProvider;
import sopt.org.HMH.global.auth.social.kakao.fegin.KakaoLoginService;

@Service
Expand All @@ -33,12 +34,13 @@ public class UserService {
private final UserRepository userRepository;
private final OnboardingInfoRepository onboardingInfoRepository;
private final KakaoLoginService kakaoLoginService;
private final AppleOAuthProvider appleOAuthProvider;

@Transactional
public LoginResponse login(String socialAccessToken, SocialPlatformRequest request) {

SocialPlatform socialPlatform = request.socialPlatform();
Long socialId = getSocialIdBySocialAccessToken(socialPlatform, socialAccessToken);
String socialId = getSocialIdBySocialAccessToken(socialPlatform, socialAccessToken);

// 유저를 찾지 못하면 404 Error를 던져 클라이언트에게 회원가입 api를 요구한다.
User loginUser = getUserBySocialPlatformAndSocialId(socialPlatform, socialId);
Expand All @@ -50,13 +52,13 @@ public LoginResponse login(String socialAccessToken, SocialPlatformRequest reque
public LoginResponse signup(String socialAccessToken, SocialSignUpRequest request) {

SocialPlatform socialPlatform = request.socialPlatform();
Long socialId = getSocialIdBySocialAccessToken(socialPlatform, socialAccessToken);
String socialId = getSocialIdBySocialAccessToken(socialPlatform, socialAccessToken);

// 이미 회원가입된 유저가 있다면 400 Error 발생
validateDuplicateUser(socialId, socialPlatform);

OnboardingInfo onboardingInfo = registerOnboardingInfo(request);
User user = addUser(socialPlatform, socialId, onboardingInfo);
User user = addUser(socialPlatform, socialId, onboardingInfo, request.name());

return performLogin(socialAccessToken, socialPlatform, user);
}
Expand Down Expand Up @@ -86,13 +88,14 @@ private void validateUserId(Long userId) {
}
}

private User getUserBySocialPlatformAndSocialId(SocialPlatform socialPlatform, Long socialId) {
private User getUserBySocialPlatformAndSocialId(SocialPlatform socialPlatform, String socialId) {
return userRepository.findBySocialPlatformAndSocialIdOrThrowException(socialPlatform, socialId);
}

private Long getSocialIdBySocialAccessToken(SocialPlatform socialPlatform, String socialAccessToken) {
private String getSocialIdBySocialAccessToken(SocialPlatform socialPlatform, String socialAccessToken) {
return switch (socialPlatform.toString()) {
case "KAKAO" -> kakaoLoginService.getSocialIdByKakao(socialAccessToken);
case "APPLE" -> appleOAuthProvider.getApplePlatformId(socialAccessToken);
default -> throw new JwtException(JwtError.INVALID_SOCIAL_ACCESS_TOKEN);
};
}
Expand All @@ -109,7 +112,7 @@ private String parseTokenString(String tokenString) {
return validSocialAccessToken;
}

private void validateDuplicateUser(Long socialId, SocialPlatform socialPlatform) {
private void validateDuplicateUser(String socialId, SocialPlatform socialPlatform) {
if (userRepository.existsBySocialPlatformAndSocialId(socialPlatform, socialId)) {
throw new UserException(UserError.DUPLICATE_USER);
}
Expand All @@ -123,12 +126,13 @@ private LoginResponse performLogin(String socialAccessToken, SocialPlatform soci
return LoginResponse.of(loginUser, tokenResponse);
}

private User addUser(SocialPlatform socialPlatform, Long socialId, OnboardingInfo onboardingInfo) {
private User addUser(SocialPlatform socialPlatform, String socialId, OnboardingInfo onboardingInfo, String name) {
User user = User.builder()
.socialPlatform(socialPlatform)
.socialId(socialId)
.onboardingInfo(onboardingInfo)
.build();
.socialPlatform(socialPlatform)
.socialId(socialId)
.onboardingInfo(onboardingInfo)
.name(name)
.build();
userRepository.save(user);
return user;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ public enum JwtError implements ErrorBase {
INVALID_SOCIAL_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 소셜 엑세스 토큰입니다."),
INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 액세스 토큰입니다. 액세스 토큰을 재발급 받아주세요."),
INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다. 다시 로그인 해주세요."),
INVALID_IDENTITY_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 IDENTITY 토큰입니다. 다시 로그인 해주세요."),
UNABLE_TO_CREATE_APPLE_PUBLIC_KEY(HttpStatus.UNAUTHORIZED, "apple public key를 생성할 수 없습니다."),
EXPIRED_IDENTITY_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 IDENTITY 토큰입니다."),
INVALID_IDENTITY_TOKEN_CLAIMS(HttpStatus.UNAUTHORIZED, "유효하지 않은 IDENTITY 토큰 claim입니다."),

// 403 FORBIDDEN

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
public enum SocialPlatform {

KAKAO("kakao"),
APPLE("apple")
;

private final String value;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package sopt.org.HMH.global.auth.social.apple.fegin;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import sopt.org.HMH.global.auth.social.apple.request.ApplePublicKeys;

@FeignClient(name = "appleFeignClient", url = "${oauth2.apple.base-url}")
public interface AppleFeignClient {
@GetMapping("/auth/keys")
ApplePublicKeys getApplePublicKeys();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package sopt.org.HMH.global.auth.social.apple.fegin;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.*;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.PublicKey;
import java.util.Base64;
import java.util.Map;
import sopt.org.HMH.global.auth.jwt.exception.JwtError;

@Component
public class AppleIdentityTokenParser {

private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

public Map<String, String> parseHeaders(String identityToken) {
try {
String encoded = identityToken.split("\\.")[0];;
String decoded = new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8);
return OBJECT_MAPPER.readValue(decoded, Map.class);
} catch (JsonProcessingException | ArrayIndexOutOfBoundsException e) {
throw new JwtException(JwtError.INVALID_IDENTITY_TOKEN.getErrorMessage());
}
}

public Claims parseWithPublicKeyAndGetClaims(String identityToken, PublicKey publicKey) {
try {
return getJwtParser(publicKey)
.parseClaimsJws(identityToken)
.getBody();
} catch (ExpiredJwtException e) {
throw new JwtException(JwtError.EXPIRED_IDENTITY_TOKEN.getErrorMessage());
} catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) {
throw new JwtException(JwtError.INVALID_IDENTITY_TOKEN.getErrorMessage());
}
}

private JwtParser getJwtParser(Key key) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package sopt.org.HMH.global.auth.social.apple.fegin;

import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class AppleIdentityTokenValidator {
@Value("${oauth2.apple.iss}")
private String iss;
@Value("${oauth2.apple.client-id}")
private String clientId;

public boolean isValidAppleIdentityToken(Claims claims) {
return claims.getIssuer().contains(iss)
&& claims.getAudience().equals(clientId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package sopt.org.HMH.global.auth.social.apple.fegin;

import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.security.PublicKey;
import java.util.Map;
import sopt.org.HMH.global.auth.jwt.exception.JwtError;
import sopt.org.HMH.global.auth.jwt.exception.JwtException;
import sopt.org.HMH.global.auth.social.apple.request.ApplePublicKeys;

@RequiredArgsConstructor
@Component
public class AppleOAuthProvider {
private final AppleFeignClient appleFeignClient;
private final AppleIdentityTokenParser appleIdentityTokenParser;
private final ApplePublicKeyGenerator applePublicKeyGenerator;
private final AppleIdentityTokenValidator appleIdentityTokenValidator;

public String getApplePlatformId(String identityToken) {
Map<String, String> headers = appleIdentityTokenParser.parseHeaders(identityToken);
ApplePublicKeys applePublicKeys = appleFeignClient.getApplePublicKeys();
PublicKey publicKey = applePublicKeyGenerator.generatePublicKeyWithApplePublicKeys(headers, applePublicKeys);
Claims claims = appleIdentityTokenParser.parseWithPublicKeyAndGetClaims(identityToken, publicKey);
validateClaims(claims);
return claims.getSubject();
}

private void validateClaims(Claims claims) {
if (!appleIdentityTokenValidator.isValidAppleIdentityToken(claims)) {
throw new JwtException(JwtError.INVALID_IDENTITY_TOKEN_CLAIMS);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package sopt.org.HMH.global.auth.social.apple.fegin;

import org.springframework.stereotype.Component;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
import java.util.Map;
import java.math.BigInteger;
import sopt.org.HMH.global.auth.jwt.exception.JwtError;
import sopt.org.HMH.global.auth.jwt.exception.JwtException;
import sopt.org.HMH.global.auth.social.apple.request.ApplePublicKey;
import sopt.org.HMH.global.auth.social.apple.request.ApplePublicKeys;

@Component
public class ApplePublicKeyGenerator {
public PublicKey generatePublicKeyWithApplePublicKeys(Map<String, String> headers, ApplePublicKeys applePublicKeys) {
ApplePublicKey applePublicKey = applePublicKeys
.getMatchesKey(headers.get("alg"), headers.get("kid"));

byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n());
byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e());

RSAPublicKeySpec rsaPublicKeySpec = new RSAPublicKeySpec(
new BigInteger(1, nBytes), new BigInteger(1, eBytes));

try {
KeyFactory keyFactory = KeyFactory.getInstance(applePublicKey.kty());
return keyFactory.generatePublic(rsaPublicKeySpec);
} catch (NoSuchAlgorithmException | InvalidKeySpecException exception) {
throw new JwtException(JwtError.UNABLE_TO_CREATE_APPLE_PUBLIC_KEY);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package sopt.org.HMH.global.auth.social.apple.request;

public record ApplePublicKey(
String kty,
String kid,
String use,
String alg,
String n,
String e) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package sopt.org.HMH.global.auth.social.apple.request;

import java.util.List;
import sopt.org.HMH.global.auth.jwt.exception.JwtError;
import sopt.org.HMH.global.auth.jwt.exception.JwtException;

public class ApplePublicKeys {
private List<ApplePublicKey> keys;

public ApplePublicKey getMatchesKey(String alg, String kid) {
return keys.stream()
.filter(applePublicKey -> applePublicKey.alg().equals(alg) && applePublicKey.kid().equals(kid))
.findFirst()
.orElseThrow(() -> new JwtException(JwtError.INVALID_IDENTITY_TOKEN));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import sopt.org.HMH.global.auth.social.kakao.request.KakaoUserRequest;

@FeignClient(name = "kakaoApiClient", url = "${oauth2.kakao.base-url}")
public interface KakaoApiClient {
public interface KakaoFeignClient {

@GetMapping(value = "/v2/user/me")
KakaoUserRequest getUserInformation(@RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,22 @@
@RequiredArgsConstructor
public class KakaoLoginService {

private final KakaoApiClient kakaoApiClient;
private final KakaoFeignClient kakaoFeignClient;

/**
* 카카오 Acess Token으로 유저의 소셜 Id 불러오는 함수
*/
public Long getSocialIdByKakao(String socialAccessToken) {
public String getSocialIdByKakao(String socialAccessToken) {

KakaoUserRequest userResponse = kakaoApiClient.getUserInformation(socialAccessToken);
System.out.println("userResponse : " + userResponse);
return userResponse.id();
KakaoUserRequest userResponse = kakaoFeignClient.getUserInformation(socialAccessToken);
return String.valueOf(userResponse.id());
}

/**
* 카카오 Access Token으로 유저 정보 업데이트
*/
public void updateUserInfoByKakao(User loginUser, String socialAccessToken) {
KakaoUserRequest userResponse = kakaoApiClient.getUserInformation(socialAccessToken);
KakaoUserRequest userResponse = kakaoFeignClient.getUserInformation(socialAccessToken);

String nickname = userResponse.kakaoAccount().profile().nickname();
String profileImageUrl = userResponse.kakaoAccount().profile().profileImageUrl();
Expand Down