Skip to content

Commit

Permalink
feat: 애플로그인 (#181)
Browse files Browse the repository at this point in the history
* test: apple auth

* chore: appleTest RequestBody => RequestParam으로 변경

* feat: apple 토큰 검증 코드 작성

* feat: apple 로그인 구현

* chore: application-prod.yml, dev.yml 수정

* chore: 불필요한 로그 삭제

* chore: LayerApplication 불필요한 주석 삭제

* chore: 필요없는 빈 주입 제거

* chore: AppleService 빈 주입 제거

---------

Co-authored-by: Raymond <[email protected]>
  • Loading branch information
clean2001 and raymondanythings authored Aug 25, 2024
1 parent 41c1ee3 commit 06c1421
Show file tree
Hide file tree
Showing 17 changed files with 307 additions and 7 deletions.
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ project(":layer-external") {
implementation project(path: ':layer-common')
implementation project(path: ':layer-domain')

implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'

annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'

implementation 'com.google.api-client:google-api-client:2.0.0'
Expand Down
2 changes: 1 addition & 1 deletion layer-api/src/main/java/org/layer/LayerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
@SpringBootApplication
@EnableJpaAuditing
@EnableAspectJAutoProxy
@EnableFeignClients(basePackages = "org.layer.external.ai.infra")
@EnableFeignClients
public class LayerApplication {
public static void main(String[] args) {
SpringApplication.run(LayerApplication.class, args);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ private void setHttp(HttpSecurity http) throws Exception {
.requestMatchers(new AntPathRequestMatcher("/api/auth/test")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/h2-console/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/external/image/presigned")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/auth/oauth2/apple")).permitAll()
.anyRequest().authenticated()
)
.headers(headers -> headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
import org.layer.common.annotation.MemberId;
import org.layer.domain.auth.controller.dto.*;
import org.layer.domain.auth.service.AuthService;
import org.layer.domain.member.repository.MemberRepository;
import org.layer.domain.member.entity.SocialType;
import org.layer.oauth.service.GoogleService;
import org.layer.oauth.service.KakaoService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@Slf4j
@RequiredArgsConstructor
@RequestMapping("/api/auth")
Expand All @@ -21,7 +23,6 @@ public class AuthController implements AuthApi {
private final AuthService authService;
private final GoogleService googleService;
private final KakaoService kakaoService;
private final MemberRepository memberRepository;

private static final String SOCIAL_TOKEN_NAME = "X-AUTH-TOKEN";

Expand Down Expand Up @@ -58,8 +59,7 @@ public ResponseEntity<?> withdraw(@MemberId Long memberId, WithdrawMemberRequest

// 토큰 재발급
@PostMapping("/reissue-token")
public ResponseEntity<ReissueTokenResponse> reissueToken(@RequestHeader(value = "Refresh", required = false) String refreshToken,
ReissueTokenRequest reissueTokenRequest) {
public ResponseEntity<ReissueTokenResponse> reissueToken(@RequestHeader(value = "Refresh", required = false) String refreshToken, ReissueTokenRequest reissueTokenRequest) {
return new ResponseEntity<>(
ReissueTokenResponse.of(authService.reissueToken(refreshToken, reissueTokenRequest.memberId())),
HttpStatus.CREATED);
Expand All @@ -72,6 +72,13 @@ public MemberInfoResponse getMemberInfo(@MemberId Long memberId) {
return authService.getMemberInfo(memberId);
}

@DisableSwaggerSecurity
@PostMapping("/oauth2/apple")
public ResponseEntity<SignInResponse> appleLogin(@RequestParam Map<String, String> body) {
SignInResponse signInResponse = authService.signIn(body.get("id_token"), SocialType.APPLE);
return new ResponseEntity<>(signInResponse, HttpStatus.OK);
}

@DisableSwaggerSecurity
//== google OAuth2 test용 API 액세스 토큰 발급 ==//
@GetMapping("/oauth2/google")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.layer.oauth.dto.service.MemberInfoServiceResponse;
import org.layer.oauth.service.GoogleService;
import org.layer.oauth.service.KakaoService;
import org.layer.oauth.service.apple.AppleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -28,6 +29,7 @@
public class AuthService {
private final KakaoService kakaoService;
private final GoogleService googleService;
private final AppleService appleService;
private final JwtService jwtService;
private final MemberService memberService;

Expand Down Expand Up @@ -119,6 +121,7 @@ private MemberInfoServiceResponse getMemberInfo(SocialType socialType, String so
return switch (socialType) {
case KAKAO -> kakaoService.getMemberInfo(socialAccessToken);
case GOOGLE -> googleService.getMemberInfo(socialAccessToken);
case APPLE -> appleService.getMemberInfo(socialAccessToken);
default -> throw new BaseCustomException(AuthExceptionType.INVALID_SOCIAL_TYPE);
};
}
Expand Down
5 changes: 5 additions & 0 deletions layer-api/src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ google:
id: ${GOOGLE_SHEET_ID}
token_path: ${DEV_GOOGLE_TOKEN_PATH}

apple:
login:
issuer: ${DEV_APPLE_ISSUER}
client_id: ${DEV_APPLE_CLIENT_ID}
audience: ${DEV_APPLE_AUD}

webmvc:
cors:
Expand Down
6 changes: 6 additions & 0 deletions layer-api/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ google:
id: ${GOOGLE_SHEET_ID}
token_path: ${LOCAL_GOOGLE_TOKEN_PATH}

apple:
login:
issuer: ${DEV_APPLE_ISSUER}
client_id: ${DEV_APPLE_CLIENT_ID}
audience: ${DEV_APPLE_AUD}



webmvc:
Expand Down
6 changes: 6 additions & 0 deletions layer-api/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ google:
id: ${GOOGLE_SHEET_ID}
token_path: ${PROD_GOOGLE_TOKEN_PATH}

apple:
login:
issuer: ${DEV_APPLE_ISSUER}
client_id: ${DEV_APPLE_CLIENT_ID}
audience: ${DEV_APPLE_AUD}


webmvc:
cors:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public enum TokenExceptionType implements ExceptionType {
INVALID_TOKEN(HttpStatus.BAD_REQUEST, "token이 유효하지 않습니다."),
INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "refresh token이 유효하지 않습니다."),
INVALID_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "access token이 유효하지 않습니다."),
NO_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "refresh token이 존재하지 않습니다.");
NO_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "refresh token이 존재하지 않습니다."),
INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 apple token입니다.");


private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package org.layer.domain.member.entity;

public enum SocialType {
KAKAO, GOOGLE, NONE
KAKAO,
GOOGLE,
APPLE,
NONE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.layer.oauth.config;


import org.layer.oauth.dto.service.apple.ApplePublicKeys;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.GetMapping;


@FeignClient(name = "apple-public-key", url = "https://appleid.apple.com")
@Component
public interface AppleAuthClient {

@GetMapping("/auth/keys")
ApplePublicKeys getApplePublicKeys();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.layer.oauth.dto.service.apple;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;

@Getter
public class ApplePublicKey {
private final String kty;

private final String kid;

private final String use;

private final String alg;

private final String n;

private final String e;

public boolean isSameAlg(final String alg) {
return this.alg.equals(alg);
}

public boolean isSameKid(final String kid) {
return this.kid.equals(kid);
}

@JsonCreator
public ApplePublicKey(@JsonProperty("kty") final String kty,
@JsonProperty("kid") final String kid,
@JsonProperty("use") final String use,
@JsonProperty("alg") final String alg,
@JsonProperty("n") final String n,
@JsonProperty("e") final String e) {
this.kty = kty;
this.kid = kid;
this.use = use;
this.alg = alg;
this.n = n;
this.e = e;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package org.layer.oauth.dto.service.apple;

import org.springframework.stereotype.Component;

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;

@Component
public class ApplePublicKeyGenerator {

private static final String SIGN_ALGORITHM_HEADER = "alg";
private static final String KEY_ID_HEADER = "kid";
private static final int POSITIVE_SIGN_NUMBER = 1;

public PublicKey generate(final Map<String, String> headers, final ApplePublicKeys publicKeys) {
final ApplePublicKey applePublicKey = publicKeys.getMatchingKey(
headers.get(SIGN_ALGORITHM_HEADER),
headers.get(KEY_ID_HEADER)
);
return generatePublicKey(applePublicKey);
}

private PublicKey generatePublicKey(final ApplePublicKey applePublicKey) {
final byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.getN());
final byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.getE());

final BigInteger n = new BigInteger(POSITIVE_SIGN_NUMBER, nBytes);
final BigInteger e = new BigInteger(POSITIVE_SIGN_NUMBER, eBytes);
final RSAPublicKeySpec rsaPublicKeySpec = new RSAPublicKeySpec(n, e);

try {
final KeyFactory keyFactory = KeyFactory.getInstance(applePublicKey.getKty());
return keyFactory.generatePublic(rsaPublicKeySpec);
} catch (NoSuchAlgorithmException | InvalidKeySpecException exception) {
throw new RuntimeException("잘못된 애플 키");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.layer.oauth.dto.service.apple;

import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;

@Getter
@NoArgsConstructor
public class ApplePublicKeys {

private List<ApplePublicKey> keys;

public ApplePublicKeys(List<ApplePublicKey> keys) {
this.keys = List.copyOf(keys);
}

public ApplePublicKey getMatchingKey(final String alg, final String kid) {
return keys.stream()
.filter(key -> key.isSameAlg(alg) && key.isSameKid(kid))
.findFirst()
.orElseThrow(() -> new RuntimeException("잘못된 토큰 형태입니다."));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.layer.oauth.dto.service.apple;


import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.UnsupportedJwtException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.security.PublicKey;
import java.util.Base64;
import java.util.Map;

@RequiredArgsConstructor
@Component
public class AppleTokenParser {

private static final String IDENTITY_TOKEN_VALUE_DELIMITER = "\\.";
private static final int HEADER_INDEX = 0;

private final ObjectMapper objectMapper;

public Map parseHeader(final String appleToken) {
try {
final String encodedHeader = appleToken.split(IDENTITY_TOKEN_VALUE_DELIMITER)[HEADER_INDEX];
final String decodedHeader = new String(Base64.getDecoder().decode(encodedHeader));
return objectMapper.readValue(decodedHeader, Map.class);
} catch (JsonMappingException e) {
throw new RuntimeException("appleToken 값이 jwt 형식인지, 값이 정상적인지 확인해주세요.");
} catch (JsonProcessingException e) {
throw new RuntimeException("디코드된 헤더를 Map 형태로 분류할 수 없습니다. 헤더를 확인해주세요.");
}
}

public Claims extractClaims(final String appleToken, final PublicKey publicKey) {
try {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(appleToken)
.getPayload();
} catch (UnsupportedJwtException e) {
throw new UnsupportedJwtException("지원되지 않는 jwt 타입");
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("비어있는 jwt");
} catch (JwtException e) {
throw new JwtException("jwt 검증 or 분석 오류");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.layer.oauth.exception;

import org.layer.common.exception.BaseCustomException;
import org.layer.common.exception.ExceptionType;

public class OAuthException extends BaseCustomException {
public OAuthException(ExceptionType exceptionType) {
super(exceptionType);
}
}
Loading

0 comments on commit 06c1421

Please sign in to comment.