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 all 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 &
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import sopt.org.HMH.domain.user.dto.response.UserInfoResponse;
import sopt.org.HMH.domain.user.service.UserService;
import sopt.org.HMH.global.auth.jwt.TokenResponse;
import sopt.org.HMH.global.common.Util;
import sopt.org.HMH.global.common.UserIdConvertor;
import sopt.org.HMH.global.common.response.ApiResponse;
import sopt.org.HMH.global.common.response.EmptyJsonResponse;

Expand Down Expand Up @@ -58,7 +58,7 @@ public ResponseEntity<ApiResponse<TokenResponse>> orderReissue(

@PostMapping("/logout")
public ResponseEntity<ApiResponse<?>> orderLogout(Principal principal) {
userService.logout(Util.getUserId(principal));
userService.logout(UserIdConvertor.getUserId(principal));
return ResponseEntity
.status(UserSuccess.LOGOUT_SUCCESS.getHttpStatus())
.body(ApiResponse.success(UserSuccess.LOGOUT_SUCCESS, new EmptyJsonResponse()));
Expand All @@ -68,6 +68,6 @@ public ResponseEntity<ApiResponse<?>> orderLogout(Principal principal) {
public ResponseEntity<ApiResponse<UserInfoResponse>> orderGetUserInfo(Principal principal) {
return ResponseEntity
.status(UserSuccess.GET_USER_INFO_SUCCESS.getHttpStatus())
.body(ApiResponse.success(UserSuccess.GET_USER_INFO_SUCCESS, userService.getUserInfo(Util.getUserId(principal))));
.body(ApiResponse.success(UserSuccess.GET_USER_INFO_SUCCESS, userService.getUserInfo(UserIdConvertor.getUserId(principal))));
}
}
21 changes: 12 additions & 9 deletions src/main/java/sopt/org/HMH/domain/user/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,36 +13,30 @@
import jakarta.persistence.Table;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sopt.org.HMH.domain.challenge.domain.Challenge;
import sopt.org.HMH.global.auth.social.SocialPlatform;
import sopt.org.HMH.global.common.domain.BaseTimeEntity;
import sopt.org.HMH.global.common.domain.PointConstants;

@Getter
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class User extends BaseTimeEntity {

@Id
@Column(name = "user_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;

@Enumerated(EnumType.STRING)
private SocialPlatform socialPlatform;

private Long socialId;

@Builder.Default
private Integer point = 0;
private String socialId;
private Integer point;

@Column(columnDefinition = "TEXT")
private String profileImageUrl;
Expand All @@ -54,6 +48,15 @@ public class User extends BaseTimeEntity {
@OneToMany(mappedBy = "user")
private List<Challenge> challenges;

@Builder
public User(SocialPlatform socialPlatform, String socialId, String name, OnboardingInfo onboardingInfo) {
this.socialPlatform = socialPlatform;
this.socialId = socialId;
this.name = name;
this.onboardingInfo = onboardingInfo;
this.point = PointConstants.INITIAL_POINT.getPoint();
}

public void updateSocialInfo(String nickname, String profileImageUrl) {
this.name = nickname;
this.profileImageUrl = profileImageUrl;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,11 @@ public enum UserError implements ErrorBase {
INVALID_USER(HttpStatus.BAD_REQUEST, "Principle 객체가 없습니다."),
DUPLICATE_USER(HttpStatus.BAD_REQUEST, "이미 회원가입된 유저입니다."),

// 401 UNAUTHORIZED

// 403 FORBIDDEN
NOT_SIGNUP_USER(HttpStatus.FORBIDDEN, "회원가입된 유저가 아닙니다. 회원가입을 진행해주세요."),

// 404 NOT FOUND
NOT_FOUND_USER(HttpStatus.NOT_FOUND, "User를 찾을 수 없습니다."),
NOT_SIGNUP_USER(HttpStatus.NOT_FOUND, "회원가입된 유저가 아닙니다. 회원가입을 진행해주세요.");
NOT_FOUND_USER(HttpStatus.NOT_FOUND, "User를 찾을 수 없습니다.");

private final HttpStatus status;
private final String errorMessage;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
@AllArgsConstructor
public enum UserSuccess implements SuccessBase {

// 200 OK
LOGIN_SUCCESS(HttpStatus.OK, "로그인에 성공했습니다."),
SIGNUP_SUCCESS(HttpStatus.OK, "회원가입에 성공했습니다."),
REISSUE_SUCCESS(HttpStatus.OK, "토큰 재발급에 성공했습니다."),
LOGOUT_SUCCESS(HttpStatus.OK, "로그아웃에 성공했습니다."),
GET_USER_INFO_SUCCESS(HttpStatus.OK, "유저의 정보를 불러오는데에 성공했습니다."),

// 201 CREATED
SIGNUP_SUCCESS(HttpStatus.CREATED, "회원가입에 성공했습니다."),
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@

public record SocialSignUpRequest(
SocialPlatform socialPlatform,

String name,
@JsonProperty(value = "onboarding")
OnboardingRequest onboardingRequest,

@JsonProperty(value = "challenge")
ChallengeRequest challengeRequest
) {
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);
}
19 changes: 11 additions & 8 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 All @@ -70,7 +72,6 @@ public TokenResponse reissueToken(String refreshToken) {
return jwtProvider.issueToken(new UserAuthentication(userId, null, null));
}

@Transactional
public void logout(Long userId) {
jwtProvider.deleteRefreshToken(userId);
}
Expand All @@ -86,13 +87,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 +111,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,10 +125,11 @@ 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)
.name(name)
.onboardingInfo(onboardingInfo)
.build();
userRepository.save(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,12 @@
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,19 @@
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);
}
}
}
Loading