Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
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
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 @@ -34,14 +34,27 @@ public enum ErrorCode {
KAKAO_UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 420, "카카오 로그인 중 알 수 없는 오류가 발생했습니다."),
KAKAO_JWT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 421, "카카오 jwt 토큰 제공시 에러"),


REQUIRED_TERMS_NOT_AGREED(BAD_REQUEST, 429, "필수 약관에 동의하지 않았습니다."),

USER_NOT_FOUND(BAD_REQUEST, 427, "존재하지 않는 회원입니다."),

DUPLICATE_USER(BAD_REQUEST, 428, "중복된 회원정보입니다."),

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, "이름이 존재하지 않습니다."),

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 파싱에 실패했습니다."),
APPLE_TOKEN_INVALID(BAD_REQUEST, 435, "Apple 토큰이 유효하지 않습니다."),



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
Contributor 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
@@ -1,5 +1,17 @@
package com.usememo.jugger.global.security.token.controller;

import static com.fasterxml.jackson.databind.type.LogicalType.*;

import java.util.Map;
import java.util.UUID;
import java.util.logging.Logger;

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;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand All @@ -8,6 +20,13 @@

import com.usememo.jugger.global.exception.BaseException;
import com.usememo.jugger.global.exception.ErrorCode;

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.repository.RefreshTokenRepository;

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;
Expand All @@ -17,6 +36,7 @@
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.service.GoogleOAuthService;
import com.usememo.jugger.global.security.token.service.KakaoOAuthService;

Expand All @@ -33,6 +53,7 @@ public class AuthController {

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

@Operation(summary = "[POST] refresh token으로 새로운 access token 발급")
@PostMapping(value = "/refresh")
Expand Down Expand Up @@ -82,4 +103,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;
private String redirectUri;
}
Loading