-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
#12 feat: Sign in with Apple
- Loading branch information
Showing
20 changed files
with
525 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
11 changes: 11 additions & 0 deletions
11
src/main/java/com/dnd/dndtravel/auth/apple/AppleClient.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
28
src/main/java/com/dnd/dndtravel/auth/apple/AppleOauthService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} |
45 changes: 45 additions & 0 deletions
45
src/main/java/com/dnd/dndtravel/auth/apple/ApplePublicKeyGenerator.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
54
src/main/java/com/dnd/dndtravel/auth/apple/AppleTokenParser.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
42
src/main/java/com/dnd/dndtravel/auth/apple/dto/ApplePublicKey.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
14 changes: 14 additions & 0 deletions
14
src/main/java/com/dnd/dndtravel/auth/apple/dto/ApplePublicKeys.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
14
src/main/java/com/dnd/dndtravel/auth/apple/dto/AppleUser.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
50
src/main/java/com/dnd/dndtravel/auth/config/JwtFilter.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
102
src/main/java/com/dnd/dndtravel/auth/config/JwtProvider.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.