Skip to content

Commit

Permalink
Merge pull request #20 from 9oormthon-univ/feat/apple-login
Browse files Browse the repository at this point in the history
[FEAT] Apple 로그인 구현
  • Loading branch information
ryuwon2407 authored Nov 21, 2024
2 parents 2760003 + 4fa1a82 commit caa42ad
Show file tree
Hide file tree
Showing 11 changed files with 258 additions and 13 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.groom.swipo.domain.auth.dto;

public record ApplePublicKey(
String kty,
String kid,
String alg,
String n,
String e) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.groom.swipo.domain.auth.dto.request;

public record AppleLoginRequest (
String token
){
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.groom.swipo.domain.auth.dto.response;

import java.util.List;
import javax.naming.AuthenticationException;

import com.groom.swipo.domain.auth.dto.ApplePublicKey;

public record ApplePublicKeyResponse(List<ApplePublicKey> keys) {
public ApplePublicKey getMatchedKey(String kid, String alg) throws AuthenticationException {
return keys.stream()
.filter(key -> key.kid().equals(kid) && key.alg().equals(alg))
.findAny()
.orElseThrow(AuthenticationException::new);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@
import lombok.Builder;

@Builder
public record KakaoLoginResponse(
public record SocialLoginResponse(
Long userId,
String accessToken,
String refreshToken,
String providerId,
String profileImage
) {
public static KakaoLoginResponse of(Long userId, String accessToken, String refreshToken) {
return KakaoLoginResponse.builder()
public static SocialLoginResponse of(Long userId, String accessToken, String refreshToken) {
return SocialLoginResponse.builder()
.userId(userId)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}

public static KakaoLoginResponse of(String providerId, String profileImage) {
return KakaoLoginResponse.builder()
public static SocialLoginResponse of(String providerId, String profileImage) {
return SocialLoginResponse.builder()
.providerId(providerId)
.profileImage(profileImage)
.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.groom.swipo.domain.auth.exception;

import com.groom.swipo.global.error.exception.AuthGroupException;

public class AppleAuthException extends AuthGroupException {
public AppleAuthException(String message) {
super(message);
}
public AppleAuthException() {
this("애플 서버와의 통신 과정에서 문제가 발생했습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.groom.swipo.domain.auth.service;

import java.net.URI;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.util.Map;
import javax.naming.AuthenticationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestClient;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.groom.swipo.domain.auth.dto.response.ApplePublicKeyResponse;
import com.groom.swipo.domain.auth.dto.response.SocialLoginResponse;
import com.groom.swipo.domain.auth.exception.AppleAuthException;
import com.groom.swipo.domain.auth.exception.InvalidTokenException;
import com.groom.swipo.domain.auth.util.ApplePublicKeyGenerator;
import com.groom.swipo.domain.auth.util.JwtValidator;
import com.groom.swipo.domain.user.entity.User;
import com.groom.swipo.domain.user.entity.enums.Provider;
import com.groom.swipo.domain.user.repository.UserRepository;
import com.groom.swipo.global.jwt.TokenProvider;

import io.jsonwebtoken.ExpiredJwtException;
import lombok.RequiredArgsConstructor;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AppleLoginService {

private static final String APPLE_PUBLIC_KEY_URL = "https://appleid.apple.com/auth/keys";

private final ApplePublicKeyGenerator applePublicKeyGenerator;
private final JwtValidator jwtValidator;
private final TokenProvider tokenProvider;
private final TokenRenewService tokenRenewService;
private final RestClient restClient;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;

@Transactional
public SocialLoginResponse appleLogin(String code) {
try {
String appleAccountId = getAppleAccountId(code);
return userRepository.findByProviderAndProviderId(Provider.APPLE, appleAccountId)
.map(this::handleExistingUserLogin)
.orElseGet(() -> SocialLoginResponse.of(appleAccountId, "default 이미지입니다."));
} catch (JsonProcessingException e) {
throw new InvalidTokenException("JSON 형식이 잘못되었습니다.");
} catch (AuthenticationException e) {
throw new AppleAuthException("인증 실패");
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new InvalidTokenException("유효하지 않는 identity Token 입니다.");
}
catch (IllegalArgumentException e) {
throw new InvalidTokenException("토큰이 만료되었거나 잘못된 인자가 포함되어있습니다.");
}
}

private SocialLoginResponse handleExistingUserLogin(User user) {
String accessToken = tokenProvider.createAccessToken(user);
String refreshToken = tokenProvider.createRefreshToken(user);
tokenRenewService.saveRefreshToken(refreshToken, user.getId());
return SocialLoginResponse.of(user.getId(), accessToken, refreshToken);
}

private String getAppleAccountId(String identityToken)
throws JsonProcessingException, AuthenticationException, NoSuchAlgorithmException, InvalidKeySpecException {
if (identityToken == null) {
throw new IllegalArgumentException("토큰이 비어있습니다.");
}

Map<String, String> headers = jwtValidator.parseHeaders(identityToken);
ApplePublicKeyResponse applePublicKeys = getAppleAuthPublicKey();
PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, applePublicKeys);

try {
return jwtValidator.getTokenClaims(identityToken, publicKey).getSubject();
} catch (ExpiredJwtException e) {
throw new IllegalArgumentException("토큰이 만료되었습니다.", e);
}
}

private ApplePublicKeyResponse getAppleAuthPublicKey() throws JsonProcessingException {
String response = restClient.get()
.uri(URI.create(APPLE_PUBLIC_KEY_URL))
.retrieve()
.body(String.class);

return objectMapper.readValue(response, ApplePublicKeyResponse.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.groom.swipo.domain.auth.dto.response.KakaoLoginResponse;
import com.groom.swipo.domain.auth.dto.response.SocialLoginResponse;
import com.groom.swipo.domain.auth.exception.KakaoAuthException;
import com.groom.swipo.domain.user.entity.User;
import com.groom.swipo.domain.user.entity.enums.Provider;
Expand Down Expand Up @@ -46,24 +46,24 @@ public class KakaoLoginService {
private String redirectUri;

@Transactional
public KakaoLoginResponse kakaoLogin(String code) {
public SocialLoginResponse kakaoLogin(String code) {
try {
String kakaoAccessToken = getKakaoAccessToken(code);
String[] userInfo = getKakaoUserInfo(kakaoAccessToken);

return userRepository.findByProviderAndProviderId(Provider.KAKAO, userInfo[0])
.map(this::handleExistingUserLogin)
.orElseGet(() -> KakaoLoginResponse.of(userInfo[0], userInfo[1]));
.orElseGet(() -> SocialLoginResponse.of(userInfo[0], userInfo[1]));
} catch (JsonProcessingException e) {
throw new KakaoAuthException();
}
}

private KakaoLoginResponse handleExistingUserLogin(User user) {
private SocialLoginResponse handleExistingUserLogin(User user) {
String accessToken = tokenProvider.createAccessToken(user);
String refreshToken = tokenProvider.createRefreshToken(user);
tokenRenewService.saveRefreshToken(refreshToken, user.getId());
return KakaoLoginResponse.of(user.getId(), accessToken, refreshToken);
return SocialLoginResponse.of(user.getId(), accessToken, refreshToken);
}

private String getKakaoAccessToken(String code) throws JsonProcessingException {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.groom.swipo.domain.auth.util;

import java.math.BigInteger;
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 javax.naming.AuthenticationException;

import org.springframework.stereotype.Component;

import com.groom.swipo.domain.auth.dto.ApplePublicKey;
import com.groom.swipo.domain.auth.dto.response.ApplePublicKeyResponse;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class ApplePublicKeyGenerator {
public PublicKey generatePublicKey(Map<String, String> tokenHeaders,
ApplePublicKeyResponse applePublicKeys)
throws AuthenticationException, NoSuchAlgorithmException, InvalidKeySpecException {
ApplePublicKey publicKey = applePublicKeys.getMatchedKey(tokenHeaders.get("kid"),
tokenHeaders.get("alg"));
return getPublicKey(publicKey);
}
private PublicKey getPublicKey(ApplePublicKey publicKey)
throws NoSuchAlgorithmException, InvalidKeySpecException {
byte[] nBytes = Base64.getUrlDecoder().decode(publicKey.n());
byte[] eBytes = Base64.getUrlDecoder().decode(publicKey.e());
RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(new BigInteger(1, nBytes),
new BigInteger(1, eBytes));
KeyFactory keyFactory = KeyFactory.getInstance(publicKey.kty());
return keyFactory.generatePublic(publicKeySpec);
}
}
42 changes: 42 additions & 0 deletions src/main/java/com/groom/swipo/domain/auth/util/JwtValidator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.groom.swipo.domain.auth.util;

import java.nio.charset.StandardCharsets;
import java.security.PublicKey;
import java.util.Base64;
import java.util.Map;

import org.springframework.stereotype.Component;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;

// 해당 코드는 Jwt 전체에 대해 검증하는 것이 아닌 애플 로그인떄 받아오는 itentity token 검증용
@Component
public class JwtValidator {

public Map<String, String> parseHeaders(String token) throws JsonProcessingException {
if (token == null) {
throw new IllegalArgumentException("토큰이 비어있습니다.");
}

String header = token.split("\\.")[0];
return new ObjectMapper().readValue(decodeHeader(header), new TypeReference<Map<String, String>>() {});
}

public String decodeHeader(String token) {
return new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
}

public Claims getTokenClaims(String token, PublicKey publicKey) {
return Jwts.parserBuilder()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.groom.swipo.domain.auth.dto.request.AppleLoginRequest;
import com.groom.swipo.domain.auth.dto.request.KakaoLoginRequest;
import com.groom.swipo.domain.auth.dto.request.TokenRefreshRequest;
import com.groom.swipo.domain.auth.dto.response.KakaoLoginResponse;
import com.groom.swipo.domain.auth.dto.response.SocialLoginResponse;
import com.groom.swipo.domain.auth.dto.response.TokenRefreshResponse;
import com.groom.swipo.domain.auth.service.AppleLoginService;
import com.groom.swipo.domain.auth.service.KakaoLoginService;
import com.groom.swipo.domain.auth.service.TokenRenewService;
import com.groom.swipo.domain.user.dto.request.PhoneCheckRequest;
Expand All @@ -38,6 +40,7 @@ public class UserController {
private final UserService userService;
private final SmsService smsService;
private final KakaoLoginService kakaoLoginService;
private final AppleLoginService appleLoginService;
private final TokenRenewService tokenRenewService;

@PostMapping("/kakao")
Expand All @@ -53,8 +56,29 @@ public class UserController {
@ApiResponse(responseCode = "500", description = "서버 오류")
}
)
public ResTemplate<KakaoLoginResponse> kakaoLogin(@RequestBody KakaoLoginRequest request) {
KakaoLoginResponse data = kakaoLoginService.kakaoLogin(request.kakaoCode());
public ResTemplate<SocialLoginResponse> kakaoLogin(@RequestBody KakaoLoginRequest request) {
SocialLoginResponse data = kakaoLoginService.kakaoLogin(request.kakaoCode());
if (data.userId() == null) {
return new ResTemplate<>(HttpStatus.I_AM_A_TEAPOT, "회원가입 필요", data);
}
return new ResTemplate<>(HttpStatus.OK, "로그인 성공", data);
}

@PostMapping("/apple")
@Operation(
summary = "애플 로그인",
description = "애플 identity token을 사용하여 로그인 또는 회원가입 필요 여부를 판별합니다. DB에 사용자 정보가 있으면 로그인 성공, 없으면 회원가입 필요 상태를 반환합니다.",
security = {},
responses = {
@ApiResponse(responseCode = "200", description = "로그인 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "401", description = "인증되지 않은 요청"),
@ApiResponse(responseCode = "418", description = "회원가입 필요"),
@ApiResponse(responseCode = "500", description = "서버 오류")
}
)
public ResTemplate<SocialLoginResponse> appleLogin(@RequestBody AppleLoginRequest request) {
SocialLoginResponse data = appleLoginService.appleLogin(request.token());
if (data.userId() == null) {
return new ResTemplate<>(HttpStatus.I_AM_A_TEAPOT, "회원가입 필요", data);
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ oauth:
client-secret: ${oauth.kakao.client-secret}
redirect-uri: ${oauth.kakao.redirect-uri}

apple:
public-key-url: ${oauth.apple.public-key-url}

imp:
api:
key: ${imp.api.key}
Expand Down

0 comments on commit caa42ad

Please sign in to comment.