-
Notifications
You must be signed in to change notification settings - Fork 0
feat : apple 로그인 기능 구현 #144
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
base: dev
Are you sure you want to change the base?
Changes from all commits
05f7ca0
fcf661f
1dbbe19
a9a2b55
0c701d3
183620e
5849b72
9f3931f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
* @JjungminLee @minhyuk2 | ||
* @JjungminLee @minhyuk2 @bricksky |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,4 +15,4 @@ public class PostCalendarDto { | |
private String place; | ||
private Instant alarm; | ||
private String description; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
package com.usememo.jugger.global.config; | ||
|
||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.web.reactive.function.client.WebClient; | ||
|
||
@Configuration | ||
public class WebClientConfig { | ||
|
||
@Bean | ||
public WebClient webClient() { | ||
return WebClient.builder().build(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
package com.usememo.jugger.global.security; | ||
|
||
import com.usememo.jugger.global.security.token.domain.AppleProperties; | ||
import io.jsonwebtoken.Jwts; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.core.io.Resource; | ||
import org.springframework.core.io.ResourceLoader; | ||
import org.springframework.stereotype.Component; | ||
|
||
import java.io.BufferedReader; | ||
import java.io.IOException; | ||
import java.io.InputStreamReader; | ||
import java.security.InvalidKeyException; | ||
import java.security.KeyFactory; | ||
import java.security.NoSuchAlgorithmException; | ||
import java.security.PrivateKey; | ||
import java.security.spec.InvalidKeySpecException; | ||
import java.security.spec.PKCS8EncodedKeySpec; | ||
import java.util.Base64; | ||
import java.util.Date; | ||
import java.util.stream.Collectors; | ||
|
||
@Component | ||
public class AppleJwtGenerator { | ||
private final AppleProperties appleProperties; | ||
private final ResourceLoader resourceLoader; | ||
private static final String APPLE_AUDIENCE = "https://appleid.apple.com"; | ||
|
||
@Autowired | ||
public AppleJwtGenerator(AppleProperties appleProperties, ResourceLoader resourceLoader) { | ||
this.appleProperties = appleProperties; | ||
this.resourceLoader = resourceLoader; | ||
} | ||
|
||
public String createClientSecret() | ||
throws java.io.IOException, | ||
NoSuchAlgorithmException, | ||
InvalidKeySpecException{ | ||
Date now = new Date(); | ||
Date expiration = new Date(now.getTime() + 3600_000); | ||
|
||
return Jwts.builder() | ||
.header() | ||
.keyId(appleProperties.getKeyId()) | ||
.and() | ||
.subject(appleProperties.getClientId()) | ||
.issuer(appleProperties.getTeamId()) | ||
.audience() | ||
.add(APPLE_AUDIENCE) | ||
.and() | ||
.expiration(expiration) | ||
.signWith(getPrivateKey(), Jwts.SIG.ES256) | ||
.compact(); | ||
} | ||
|
||
private PrivateKey getPrivateKey() | ||
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { | ||
Resource resource = resourceLoader.getResource(appleProperties.getPrivateKeyLocation()); | ||
try (BufferedReader reader = | ||
new BufferedReader(new InputStreamReader(resource.getInputStream()))) { | ||
String keyContent = reader.lines().collect(Collectors.joining("\n")); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아마 이 부분때문에 애플로그인이 어렵다는 인식이 생긴 것 같네용 신기합니다~ |
||
String key = | ||
keyContent | ||
.replace("-----BEGIN PRIVATE KEY-----", "") | ||
.replace("-----END PRIVATE KEY-----", "") | ||
.replaceAll("\\s+", ""); | ||
byte[] encoded = Base64.getDecoder().decode(key); | ||
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); | ||
KeyFactory keyFactory = KeyFactory.getInstance("EC"); | ||
return keyFactory.generatePrivate(keySpec); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
package com.usememo.jugger.global.security; | ||
|
||
import com.nimbusds.jose.jwk.JWK; | ||
import com.nimbusds.jose.jwk.JWKSet; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.stereotype.Component; | ||
|
||
import java.net.URL; | ||
import java.time.Duration; | ||
import java.time.Instant; | ||
import java.util.Map; | ||
import java.util.concurrent.ConcurrentHashMap; | ||
import java.util.concurrent.locks.ReadWriteLock; | ||
import java.util.concurrent.locks.ReentrantReadWriteLock; | ||
|
||
@Slf4j | ||
@Component | ||
public class ApplePublicKeyProvider { | ||
|
||
private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys"; | ||
private static final Duration CACHE_DURATION = Duration.ofDays(15); | ||
|
||
private final Map<String, JWK> keyCache = new ConcurrentHashMap<>(); | ||
private volatile Instant lastCacheTime = Instant.MIN; | ||
private final ReadWriteLock cacheLock = new ReentrantReadWriteLock(); | ||
|
||
public JWK getKeyById(String keyId) { | ||
|
||
try { | ||
// 캐시 확인 및 만료 체크 | ||
cacheLock.readLock().lock(); | ||
try { | ||
if (keyCache.containsKey(keyId) && !isCacheExpired()) { | ||
return keyCache.get(keyId); | ||
} | ||
} finally { | ||
cacheLock.readLock().unlock(); | ||
} | ||
|
||
// 캐시 갱신 | ||
cacheLock.writeLock().lock(); | ||
try { | ||
// 다른 스레드가 갱신했는지 재확인 | ||
if (keyCache.containsKey(keyId) && !isCacheExpired()) { | ||
return keyCache.get(keyId); | ||
} | ||
// Apple 서버에서 공개키 가져오기 | ||
JWKSet jwkSet = JWKSet.load(new URL(APPLE_KEYS_URL)); | ||
for (JWK key : jwkSet.getKeys()) { | ||
keyCache.put(key.getKeyID(), key); | ||
} | ||
lastCacheTime = Instant.now(); | ||
} finally { | ||
cacheLock.writeLock().unlock(); | ||
} | ||
|
||
JWK foundKey = keyCache.get(keyId); | ||
if (foundKey == null) { | ||
throw new IllegalArgumentException("Apple 공개키에서 keyId를 찾을 수 없습니다: " + keyId); | ||
} | ||
|
||
return foundKey; | ||
} catch (Exception e) { | ||
log.error("Apple 공개키 로딩 실패", e); | ||
throw new IllegalArgumentException("Apple 공개키 로딩 실패", e); | ||
} | ||
} | ||
|
||
private boolean isCacheExpired() { | ||
return Duration.between(lastCacheTime, Instant.now()).compareTo(CACHE_DURATION) > 0; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,68 @@ | ||||||||||||||||||||||||||
package com.usememo.jugger.global.security; | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
import com.nimbusds.jose.JWSAlgorithm; | ||||||||||||||||||||||||||
import com.nimbusds.jose.JWSHeader; | ||||||||||||||||||||||||||
import com.nimbusds.jose.JWSVerifier; | ||||||||||||||||||||||||||
import com.nimbusds.jose.crypto.RSASSAVerifier; | ||||||||||||||||||||||||||
import com.nimbusds.jwt.SignedJWT; | ||||||||||||||||||||||||||
import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||
import org.springframework.beans.factory.annotation.Value; | ||||||||||||||||||||||||||
import org.springframework.stereotype.Component; | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
import java.text.ParseException; | ||||||||||||||||||||||||||
import java.util.Date; | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
@Component | ||||||||||||||||||||||||||
@RequiredArgsConstructor | ||||||||||||||||||||||||||
public class JwtValidator { | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
private static final String APPLE_ISSUER = "https://appleid.apple.com"; | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
private final ApplePublicKeyProvider keyProvider; | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
@Value("${apple.client-id}") | ||||||||||||||||||||||||||
private String clientId; | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
Comment on lines
+23
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 의존성 주입 방식 일관성 개선
- @Value("${apple.client-id}")
- private String clientId;
+ private final String clientId;
+
+ public JwtValidator(ApplePublicKeyProvider keyProvider,
+ @Value("${apple.client-id}") String clientId) {
+ this.keyProvider = keyProvider;
+ this.clientId = clientId;
+ } 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||
public SignedJWT validate(String idToken) { | ||||||||||||||||||||||||||
try { | ||||||||||||||||||||||||||
SignedJWT jwt = SignedJWT.parse(idToken); | ||||||||||||||||||||||||||
JWSHeader header = jwt.getHeader(); | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
// 알고리즘 확인 | ||||||||||||||||||||||||||
if (!JWSAlgorithm.RS256.equals(header.getAlgorithm())) { | ||||||||||||||||||||||||||
throw new IllegalArgumentException("Unexpected JWS algorithm: " + header.getAlgorithm()); | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
// 공개키로 서명 검증 | ||||||||||||||||||||||||||
var jwk = keyProvider.getKeyById(header.getKeyID()); | ||||||||||||||||||||||||||
JWSVerifier verifier = new RSASSAVerifier(jwk.toRSAKey()); | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
if (!jwt.verify(verifier)) { | ||||||||||||||||||||||||||
throw new IllegalArgumentException("Apple ID Token 서명 검증 실패"); | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
// 클레임 검증 | ||||||||||||||||||||||||||
var claims = jwt.getJWTClaimsSet(); | ||||||||||||||||||||||||||
Date now = new Date(); | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
if (claims.getExpirationTime() == null || now.after(claims.getExpirationTime())) { | ||||||||||||||||||||||||||
throw new IllegalArgumentException("Apple ID Token 만료됨"); | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
if (!APPLE_ISSUER.equals(claims.getIssuer())) { | ||||||||||||||||||||||||||
throw new IllegalArgumentException("잘못된 iss: " + claims.getIssuer()); | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
if (!claims.getAudience().contains(clientId)) { | ||||||||||||||||||||||||||
throw new IllegalArgumentException("잘못된 aud: " + claims.getAudience()); | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
return jwt; | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
} catch (ParseException e) { | ||||||||||||||||||||||||||
throw new IllegalArgumentException("Apple ID Token 파싱 실패", e); | ||||||||||||||||||||||||||
} catch (Exception e) { | ||||||||||||||||||||||||||
throw new IllegalArgumentException("Apple ID Token 검증 실패", e); | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package com.usememo.jugger.global.security.token.domain; | ||
|
||
import io.swagger.v3.oas.annotations.media.Schema; | ||
|
||
public record AppleLoginRequest(@Schema(description = "애플 인가 코드", example = "dY94g8JaZ1Ki1s...") | ||
String code) { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
package com.usememo.jugger.global.security.token.domain; | ||
|
||
import lombok.Getter; | ||
import lombok.Setter; | ||
import org.springframework.boot.context.properties.ConfigurationProperties; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Getter | ||
@Setter | ||
@Component | ||
@ConfigurationProperties(prefix = "apple") | ||
public class AppleProperties { | ||
|
||
private String clientId; | ||
private String teamId; | ||
private String keyId; | ||
private String privateKeyLocation; | ||
//private String privateKey; | ||
private String redirectUri; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
상동입니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
반영 했습니다~