Skip to content

Commit

Permalink
Merge pull request #14 from dnd-side-project/feat/#12
Browse files Browse the repository at this point in the history
#12 feat: Sign in with Apple
  • Loading branch information
youngreal authored Aug 20, 2024
2 parents 98c312a + 848a10e commit f3ebe38
Show file tree
Hide file tree
Showing 20 changed files with 525 additions and 9 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/dnd/dndtravel/auth/apple/AppleClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.dnd.dndtravel.auth.apple;

import com.dnd.dndtravel.auth.apple.dto.ApplePublicKeys;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;

@FeignClient(name = "apple-public-key", url = "https://appleid.apple.com")
public interface AppleClient {
@GetMapping("/auth/keys")
ApplePublicKeys getApplePublicKeys();
}
28 changes: 28 additions & 0 deletions src/main/java/com/dnd/dndtravel/auth/apple/AppleOauthService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.dnd.dndtravel.auth.apple;

import com.dnd.dndtravel.auth.apple.dto.ApplePublicKeys;
import com.dnd.dndtravel.auth.apple.dto.AppleUser;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

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

@RequiredArgsConstructor
@Component
public class AppleOauthService {
private static final String DEFAULT_NAME = "apple";
private static final String CLAIM_EMAIL = "email";
private final AppleTokenParser appleTokenParser;
private final AppleClient appleClient;
private final ApplePublicKeyGenerator applePublicKeyGenerator;

public AppleUser createAppleUser(final String appleToken) {
final Map<String, String> appleTokenHeader = appleTokenParser.parseHeader(appleToken);
final ApplePublicKeys applePublicKeys = appleClient.getApplePublicKeys();
final PublicKey publicKey = applePublicKeyGenerator.generate(appleTokenHeader, applePublicKeys);
final Claims claims = appleTokenParser.extractClaims(appleToken, publicKey);
return new AppleUser(DEFAULT_NAME, claims.get(CLAIM_EMAIL, String.class));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.dnd.dndtravel.auth.apple;

import com.dnd.dndtravel.auth.apple.dto.ApplePublicKey;
import com.dnd.dndtravel.auth.apple.dto.ApplePublicKeys;
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("잘못된 애플 키");
}
}
}
54 changes: 54 additions & 0 deletions src/main/java/com/dnd/dndtravel/auth/apple/AppleTokenParser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.dnd.dndtravel.auth.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.Map;

/*
JWK 리스트 조회
헤더 부분을 디코딩하여 일치하는 jwk를 찾을 alg, kid 값을 얻고, id_token Claim 추출
*/
@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<String, String> parseHeader(final String appleToken) {
try {
final String decodedHeader = appleToken.split(IDENTITY_TOKEN_VALUE_DELIMITER)[HEADER_INDEX];
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 분석 오류");
}
}
}
42 changes: 42 additions & 0 deletions src/main/java/com/dnd/dndtravel/auth/apple/dto/ApplePublicKey.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.dnd.dndtravel.auth.apple.dto;

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

/*
id_token 검증
애플 서버에서 jwk 리스트를 받아와 이를 일급 컬렉션 형태로 관리
*/
@Getter
public class ApplePublicKey {
private final String kty; //Key Type(RSA or EC(Elliptic Curve))
private final String kid; //key ID
private final String use; //퍼블릭 키가 어떤 용도로 사용되는지 명시 ("sig"(signature) or "enc"(encryption))
private final String alg; //어떤 알고리즘을 사용하는지
private final String n; //RSA modulus
private final String e; //RSA public exponent

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,14 @@
package com.dnd.dndtravel.auth.apple.dto;

import java.util.List;

public class ApplePublicKeys {
private List<ApplePublicKey> 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("잘못된 토큰 형태입니다."));
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/dnd/dndtravel/auth/apple/dto/AppleUser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.dnd.dndtravel.auth.apple.dto;

import lombok.Getter;

@Getter
public class AppleUser {
private final String name;
private final String email;

public AppleUser(final String name, final String email) {
this.name = name;
this.email = email;
}
}
50 changes: 50 additions & 0 deletions src/main/java/com/dnd/dndtravel/auth/config/JwtFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.dnd.dndtravel.auth.config;

import io.jsonwebtoken.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {

private static final String ACCESS_HEADER= "Authorization";
private static final int BEARER_SPLIT = 7;
private final JwtProvider jwtProvider;

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException, java.io.IOException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String token = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();

if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) {
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
else {
//예외 처리
System.out.println("유효한 jwt 토큰이 없습니다. uri: " + requestURI);
}

filterChain.doFilter(servletRequest, servletResponse);
}

// Request Header 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(ACCESS_HEADER);

if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(BEARER_SPLIT);
}

return null;
}
}
102 changes: 102 additions & 0 deletions src/main/java/com/dnd/dndtravel/auth/config/JwtProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.dnd.dndtravel.auth.config;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Component
public class JwtProvider implements InitializingBean {

private static final String AUTHORITIES_KEY = "memberId";
private final long tokenValidityInMilliseconds;
private final String secretKey;
private Key key;

public JwtProvider(
@Value("${JWT_SECRET_KEY}") String secretKey,
@Value("86400") long tokenValidityInSeconds) {
this.secretKey = secretKey;
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
}

// secretKey 값을 Base64 Decode 해서 key 변수에 할당
@Override
public void afterPropertiesSet() {
byte[] keyBytes = secretKey.getBytes(); //32바이트 이상
this.key = Keys.hmacShaKeyFor(keyBytes);
}

// JWT Access 토큰 생성
public String createToken(Authentication authentication) {
Long memberId = Long.parseLong(authentication.getName());
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds); //1일

return Jwts.builder()
.setSubject(String.valueOf(memberId))
.claim(AUTHORITIES_KEY, memberId)
.signWith(key, SignatureAlgorithm.HS256)
.setExpiration(validity)
.compact();
}

// Refresh 토큰 생성
public String createRefreshToken(Long memberId) {
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds * 14); // 14일

return Jwts.builder()
.setSubject(String.valueOf(memberId))
.signWith(key, SignatureAlgorithm.HS256)
.setExpiration(validity)
.compact();
}

// authentication 객체 생성
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parser()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());

return new UsernamePasswordAuthenticationToken(claims.get(AUTHORITIES_KEY), token, authorities);
}

// Token 유효성 검증
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
// 커스텀 예외 처리
System.out.println("잘못된 JWT Signature");
} catch (ExpiredJwtException e) {
System.out.println("만료된 JWT 토큰");
} catch (UnsupportedJwtException e) {
System.out.println("지원 되지 않는 JWT 토큰");
} catch (IllegalArgumentException e) {
System.out.println("잘못된 JWT 토큰");
}

return false;
}
}
Loading

0 comments on commit f3ebe38

Please sign in to comment.