Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -4,12 +4,12 @@

@Getter
public class BaseException extends RuntimeException {
private final ErrorCode errorCode;
private final String message;
private final ErrorCode errorCode;
private final String message;

public BaseException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.message = errorCode.getMessage();
}
public BaseException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.message = errorCode.getMessage();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,21 @@ public enum ErrorCode {
KAKAO_USER_NOT_FOUND(BAD_REQUEST,427,"존재하지 않는 회원입니다."),

DUPLICATE_USER(BAD_REQUEST,428,"중복된 회원정보입니다."),
REQUIRED_TERMS_NOT_AGREED(BAD_REQUEST, 429, "필수 약관에 동의하지 않았습니다."),


GOOGLE_LOGIN_FAIL(BAD_REQUEST,404,"로그인에 실패하였습니다."),
GOOGLE_NO_EMAIL(BAD_REQUEST,404,"이메일이 존재하지 않습니다."),
GOOGLE_USER_NOT_FOUND(BAD_REQUEST,404,"구글 유저가 존재하지 않습니다."),
GOOGLE_NO_NAME(BAD_REQUEST,404,"이름이 존재하지 않습니다."),

APPLE_USER_NOT_FOUND(BAD_REQUEST, 430, "존재하지 않는 Apple 회원입니다."),
APPLE_TOKEN_REQUEST_FAILED(BAD_REQUEST, 431, "Apple 인가 코드로 토큰을 요청하는 데 실패했습니다."),
APPLE_CLIENT_SECRET_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 432, "Apple client secret 생성에 실패했습니다."),
APPLE_USERINFO_MISSING(BAD_REQUEST, 433, "Apple 사용자 정보가 불완전합니다."),
APPLE_TOKEN_PARSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 434, "Apple id_token 파싱에 실패했습니다."),



JWT_KEY_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 422, "JWT 키 생성에 실패했습니다."),
JWT_ACCESS_TOKEN_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 423, "액세스 토큰 생성 실패"),
Expand Down
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);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

상동입니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영 했습니다~

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"));
Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

의존성 주입 방식 일관성 개선

@RequiredArgsConstructor를 사용하면서 @Value 어노테이션을 함께 사용하는 것은 일관성이 없습니다. 생성자 주입으로 통일하는 것을 권장합니다.

-    @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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Value("${apple.client-id}")
private String clientId;
// replace field-injected clientId with constructor injection
- @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;
+ }
🤖 Prompt for AI Agents
In src/main/java/com/usememo/jugger/global/security/JwtValidator.java around
lines 21 to 23, the clientId field is injected using @Value annotation, which is
inconsistent with the use of @RequiredArgsConstructor for dependency injection.
To fix this, remove the @Value annotation and the field injection, then add
clientId as a final field and include it as a constructor parameter so that it
is injected via constructor injection, maintaining consistency with the rest of
the class.

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
Expand Up @@ -6,7 +6,8 @@
import java.util.UUID;
import java.util.logging.Logger;

import org.apache.el.parser.Token;
import com.usememo.jugger.global.security.token.domain.*;
import com.usememo.jugger.global.security.token.service.AppleOAuthService;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
Expand All @@ -27,18 +28,7 @@
import com.usememo.jugger.global.exception.KakaoException;
import com.usememo.jugger.global.security.CustomOAuth2User;
import com.usememo.jugger.global.security.JwtTokenProvider;
import com.usememo.jugger.global.security.token.domain.GoogleLoginRequest;
import com.usememo.jugger.global.security.token.domain.GoogleSignupRequest;
import com.usememo.jugger.global.security.token.domain.KakaoLoginRequest;

import com.usememo.jugger.global.security.token.domain.KakaoLogoutResponse;
import com.usememo.jugger.global.security.token.domain.KakaoSignUpRequest;

import com.usememo.jugger.global.security.token.domain.LogOutRequest;
import com.usememo.jugger.global.security.token.domain.LogOutResponse;
import com.usememo.jugger.global.security.token.domain.NewTokenResponse;
import com.usememo.jugger.global.security.token.domain.RefreshTokenRequest;
import com.usememo.jugger.global.security.token.domain.TokenResponse;

import com.usememo.jugger.global.security.token.repository.RefreshTokenRepository;
import com.usememo.jugger.global.security.token.service.GoogleOAuthService;
import com.usememo.jugger.global.security.token.service.KakaoOAuthService;
Expand All @@ -56,6 +46,7 @@ public class AuthController {

private final KakaoOAuthService kakaoService;
private final GoogleOAuthService googleOAuthService;
private final AppleOAuthService appleOAuthService;


@Operation(summary = "[POST] refresh token으로 새로운 access token 발급")
Expand Down Expand Up @@ -109,4 +100,18 @@ public Mono<ResponseEntity<TokenResponse>> signUpGoogle(@RequestBody GoogleSignu
.map(token-> ResponseEntity.ok().body(token));
}


@Operation(summary = "[POST] 애플 로그인")
@PostMapping("/apple")
public Mono<ResponseEntity<TokenResponse>> loginByApple(@RequestBody AppleLoginRequest appleLoginRequest){
return appleOAuthService.loginWithApple(appleLoginRequest.code())
.map(token -> ResponseEntity.ok().body(token));
}

@Operation(summary = "[POST] 애플 회원가입")
@PostMapping("/apple/signup")
public Mono<ResponseEntity<TokenResponse>> signUpApple(@RequestBody AppleSignUpRequest appleSignUpRequest){
return appleOAuthService.signUpApple(appleSignUpRequest)
.map(token -> ResponseEntity.ok().body(token));
}
}
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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

privateKey와 privateKeyLocation 필드의 용도 명확화 필요

두 개의 비슷한 필드가 있어 혼란을 줄 수 있습니다. 각각의 용도를 문서화하거나 하나로 통합하는 것을 고려해주세요.


🏁 Script executed:

#!/bin/bash
# privateKey와 privateKeyLocation 필드가 실제로 어떻게 사용되는지 확인
rg -A 5 "getPrivateKey\(\)|getPrivateKeyLocation\(\)" --type java

Length of output: 2881


🏁 Script executed:

#!/bin/bash
# appleProperties.getPrivateKey() 호출 위치 확인
rg "appleProperties\\.getPrivateKey" --type java -n

Length of output: 224


privateKey와 privateKeyLocation 용도 명확화 및 정리 필요

현재 privateKeyLocation은 파일 경로로부터 키를 읽어오는 데만 사용되고, privateKey 필드는 코드에서 전혀 참조되지 않습니다. 두 필드가 각각 어떤 상황에서, 어떤 형식(예: 파일 경로 vs. 인라인 Base64)으로 사용되어야 하는지 문서화하거나, 실제 사용하지 않는 필드를 제거/통합해주세요.

조치 항목:

  • src/main/java/com/usememo/jugger/global/security/token/domain/AppleProperties.java
    privateKey/privateKeyLocation 각 필드에 대한 JavaDoc 또는 주석 추가
  • 프로젝트 설정 문서(application.yml 예시, README 등)에 두 속성 사용 방법 및 우선순위(예: privateKey 설정 시 privateKeyLocation 무시 여부) 기재
  • 만약 인라인 키 제공 기능이 필요 없다면, 사용되지 않는 privateKey 필드를 제거하거나, 반대로 인라인 키 사용 로직을 AppleJwtGenerator에 구현
🤖 Prompt for AI Agents
In
src/main/java/com/usememo/jugger/global/security/token/domain/AppleProperties.java
around lines 14 to 15, clarify the purpose and usage of the privateKey and
privateKeyLocation fields by adding JavaDoc or comments explaining when and how
each should be used (e.g., file path vs. inline Base64 key). Update project
documentation such as application.yml examples or README to describe these
properties and their precedence (e.g., whether privateKey overrides
privateKeyLocation). If inline key usage is not needed, remove the unused
privateKey field; otherwise, implement the inline key handling logic in
AppleJwtGenerator accordingly.

private String redirectUri;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.usememo.jugger.global.security.token.domain;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

public record AppleSignUpRequest(@Schema(description = "사용자 이름", example = "홍길동", required = true)
String name,

@Schema(description = "이메일 주소", example = "[email protected]", required = true)
String email,

@Schema(description = "약관 동의 정보", required = true)
KakaoSignUpRequest.Terms terms
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

잘못된 클래스 참조 수정 필요

Apple 회원가입 요청에서 KakaoSignUpRequest.Terms를 참조하고 있습니다. 자체 정의된 Terms 클래스를 사용해야 합니다.

-                                 KakaoSignUpRequest.Terms terms
+                                 Terms terms
🤖 Prompt for AI Agents
In
src/main/java/com/usememo/jugger/global/security/token/domain/AppleSignUpRequest.java
at line 13, the code incorrectly references KakaoSignUpRequest.Terms. Replace
this with the locally defined Terms class specific to AppleSignUpRequest to
ensure correct class usage and avoid cross-class dependency.

) {
@Schema(name = "Terms", description = "약관 동의 항목들")
@Data
public static class Terms {
@Schema(description = "서비스 이용약관 동의 여부", example = "true", required = true)
private boolean termsOfService;

@Schema(description = "개인정보 처리방침 동의 여부", example = "true", required = true)
private boolean privacyPolicy;

@Schema(description = "마케팅 정보 수신 동의 여부", example = "false", required = true)
private boolean marketing;
}
}
Loading