Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package org.runnect.server.auth.service;


import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import lombok.RequiredArgsConstructor;
import okhttp3.OkHttpClient;
import okhttp3.FormBody;
Expand All @@ -13,9 +20,8 @@
import org.runnect.server.common.exception.UnauthorizedException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.nimbusds.jwt.SignedJWT;

import java.text.ParseException;
import java.net.URL;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
Expand Down Expand Up @@ -45,6 +51,8 @@ public class AppleSignInService {

@Value("${apple.revoke-url}")
private String APPLE_REVOKE_URL;
private static final String APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys";

private PrivateKey PRIVATE_KEY;
@Value("${apple.p8key}")
private void getPrivateKey(String P8KEY){
Expand All @@ -61,13 +69,12 @@ private void getPrivateKey(String P8KEY){

public SocialInfoResponseDto getSocialInfo(String idToken) {


// 클라에서 준 인증토큰이 정말 애플에서 발급받은게 맞는지 확인
// (애플 공개키(JWKS)로 서명을 검증해야만 위조된 토큰을 걸러낼 수 있다 —
// 서명 검증 없이 파싱만 하면 누구나 클레임을 임의로 채운 토큰으로 로그인할 수 있음)

try{
//1. idToken을 parse
SignedJWT jwt = SignedJWT.parse(idToken);
JWTClaimsSet claimsSet = jwt.getJWTClaimsSet();
JWTClaimsSet claimsSet = verifySignatureAndGetClaims(idToken);

// 발급처, aud, 시간제한, 이메일 검증

Expand All @@ -90,13 +97,27 @@ public SocialInfoResponseDto getSocialInfo(String idToken) {

return SocialInfoResponseDto.of(claimsSet.getStringClaim("email"), claimsSet.getSubject());

}catch (ParseException e){
}catch (UnauthorizedException e){
throw e;
}catch (Exception e){
// 서명 검증 실패(BadJOSEException), 파싱 실패(ParseException), JWKS 조회 실패(JOSEException) 등
// 위조/변조된 토큰 또는 애플 검증 자체가 불가능한 경우 전부 동일하게 처리
throw new UnauthorizedException(ErrorStatus.INVALID_APPLE_ID_TOKEN_EXCEPTION,
ErrorStatus.INVALID_APPLE_ID_TOKEN_EXCEPTION.getMessage());
Comment on lines +100 to 106

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/main/java/org/runnect/server --items all --type class,method

rg -n -C 4 \
  'UnauthorizedException|RemoteKeySourceException|ResourceAccessException|HttpServerErrorException|`@ExceptionHandler`' \
  src/main/java

Repository: Runnect/Runnect-Spring-Boot-Server

Length of output: 37295


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== AppleSignInService.java =="
cat -n src/main/java/org/runnect/server/auth/service/AppleSignInService.java | sed -n '1,180p'

echo "== KakaoSignInService.java =="
cat -n src/main/java/org/runnect/server/auth/service/KakaoSignInService.java | sed -n '1,90p'

echo "== Global exception handlers =="
cat -n src/main/java/org/runnect/server/common/advice/ControllerExceptionAdvice.java | sed -n '1,130p'

echo "== ErrorStatus references =="
rg -n -C 2 'INVALID_APPLE_ID_TOKEN_EXCEPTION|INVALID_KAKAO_ID_TOKEN_EXCEPTION|INVALID_GOOGLE_ID_TOKEN_EXCEPTION|KAKAO|APPLE|GOOGLE' src/main/java src/test/java | sed -n '1,220p'

Repository: Runnect/Runnect-Spring-Boot-Server

Length of output: 40437


Do not treat provider outages as invalid-provider credentials.

These catch blocks throw INVALID_*_ID_TOKEN_EXCEPTION for any Exception, including JWKS retrieval/network failures and Kakao API 5xx/network failures. Return a retryable provider error for connectivity/timeout/server failures while keeping signature or invalid-credential failures as invalid-token errors.

📍 Affects 2 files
  • src/main/java/org/runnect/server/auth/service/AppleSignInService.java#L100-L106 (this comment)
  • src/main/java/org/runnect/server/auth/service/KakaoSignInService.java#L37-L49
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/org/runnect/server/auth/service/AppleSignInService.java` around
lines 100 - 106, Update exception handling in AppleSignInService and
KakaoSignInService so connectivity, timeout, JWKS retrieval, and provider 5xx
failures map to the existing retryable provider error, while signature, parsing,
and invalid-credential failures remain INVALID_*_ID_TOKEN_EXCEPTION; preserve
UnauthorizedException propagation and classify each failure using the
provider/client exception types already used by these services.

}

}

private JWTClaimsSet verifySignatureAndGetClaims(String idToken) throws Exception {
JWKSource<SecurityContext> keySource = new RemoteJWKSet<>(new URL(APPLE_JWKS_URL));
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
JWSVerificationKeySelector<SecurityContext> keySelector =
new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource);
jwtProcessor.setJWSKeySelector(keySelector);
// 서명이 유효하지 않으면 여기서 BadJOSEException/JOSEException이 던져진다
return jwtProcessor.process(idToken, null);
}

// id_token 형태 :
// {
// "aud": 번들아이디,
Expand Down
10 changes: 6 additions & 4 deletions src/main/java/org/runnect/server/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,12 @@ public GetNewTokenResponseDto getNewToken(String accessToken, String refreshToke
try {
// refreshToken으로 유저찾기
final long userId = Long.parseLong(tokenContents);
if(redisService.getValuesByKey(String.valueOf(userId)).isBlank()){
//탈취된 refreshToken인 경우
final String storedRefreshToken = redisService.getValuesByKey(String.valueOf(userId));
// Redis에 저장된 최신 refreshToken과 실제로 일치하는지까지 확인한다.
// (단순히 "뭔가 저장돼 있는지"만 보면, 재로그인 등으로 이미 무효화된
// 예전 refreshToken도 계속 accessToken 재발급에 쓰일 수 있었음)
if (storedRefreshToken == null || storedRefreshToken.isBlank() || !storedRefreshToken.equals(refreshToken)) {
//탈취되었거나 이미 무효화된 refreshToken인 경우
Comment on lines +55 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/main/java/org/runnect/server/auth/service/AuthService.java --items all
ast-grep outline src/main/java/org/runnect/server/config/jwt/JwtService.java --items all
ast-grep outline src/main/java/org/runnect/server/config/redis/RedisService.java --items all

rg -n -C 4 'getNewToken|issuedAccessToken|issuedRefreshToken|setValues|getValuesByKey' \
  src/main/java/org/runnect/server/auth/service/AuthService.java \
  src/main/java/org/runnect/server/config/jwt/JwtService.java \
  src/main/java/org/runnect/server/config/redis/RedisService.java

Repository: Runnect/Runnect-Spring-Boot-Server

Length of output: 9941


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,125p' src/main/java/org/runnect/server/auth/service/AuthService.java | cat -n
sed -n '1,90p' src/main/java/org/runnect/server/config/jwt/JwtService.java | cat -n
sed -n '1,55p' src/main/java/org/runnect/server/config/redis/RedisService.java | cat -n

rg -n 'setIfAbsent|setIfPresent|compareAndSet|opsForValue\(\)\.setIfAbsent|opsForValue\(\)\.compareAndSet|lock|sessionVersion|session-version|sessionVersion|invalidate|issuedRefreshToken|refreshToken' src/main/java src/test/java

Repository: Runnect/Runnect-Spring-Boot-Server

Length of output: 20604


Broken Authentication (CWE-367): Time-of-check Time-of-use (TOCTOU) Race Condition

Reachability: External · Exploitability: Moderate

Make refresh-token reissue atomic with Redis rotation.

A stolen/old refresh token can read the current Redis value, then a concurrent sign-in can replace that value before issuedAccessToken runs. The stale refresh token still receives a new access token.

Use atomic Redis state rotation for refresh-token reissue, such as comparing against the current stored value, writing an unchanged version key, and invalidating access tokens when session rotation occurs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/org/runnect/server/auth/service/AuthService.java` around lines
55 - 60, Update the refresh-token reissue flow in AuthService around
storedRefreshToken and issuedAccessToken to atomically compare-and-rotate the
Redis session state, so a token cannot reissue after a concurrent sign-in
replaces it. Ensure the rotation preserves the current refresh-token value only
when it still matches, and invalidate associated access tokens when session
rotation occurs; reject the request when the atomic check fails.

throw new InvalidRefreshTokenException(ErrorStatus.INVALID_REFRESH_TOKEN_EXCEPTION, ErrorStatus.INVALID_REFRESH_TOKEN_EXCEPTION.getMessage());
}
RunnectUser user = userRepository.findById(userId)
Expand All @@ -79,8 +83,6 @@ public GetNewTokenResponseDto getNewToken(String accessToken, String refreshToke
public AuthResponseDto signIn(SignInRequestDto signInRequestDto) {
SocialType socialType = SocialType.valueOf(signInRequestDto.getProvider());

System.out.println("타입은? "+ socialType);

SocialInfoResponseDto socialInfo = getSocialInfo(socialType, signInRequestDto.getToken());

boolean isRegistered = userRepository.existsByEmailAndProvider(socialInfo.getEmail(), socialType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,24 @@ public SocialInfoResponseDto getSocialInfo(String token) {

HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
RestTemplate rt = new RestTemplate();
ResponseEntity<String> response = rt.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.POST,
kakaoUserInfoRequest,
String.class
);

// responseBody 속 정보 꺼내기
String responseBody = response.getBody();

String userId = null;
String email = null;

try {
// 카카오 API가 4xx/5xx를 반환하면(토큰 만료/무효 등, 흔히 발생) RestTemplate이
// 예외를 던진다. 이 try 블록 밖에 있으면 여기서 잡히지 않고 그대로 500으로
// 새어나가 버리므로(실제로는 401로 처리돼야 할 흔한 케이스인데도) 같이 묶는다.
ResponseEntity<String> response = rt.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.POST,
kakaoUserInfoRequest,
String.class
);

// responseBody 속 정보 꺼내기
String responseBody = response.getBody();

JSONParser parser = new JSONParser();
JSONObject obj = (JSONObject) parser.parse(responseBody);
JSONObject kakao_account = (JSONObject) obj.get("kakao_account");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package org.runnect.server.auth.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.spec.ECGenParameterSpec;
import java.util.Base64;
import org.junit.jupiter.api.Test;
import org.runnect.server.common.exception.UnauthorizedException;
import org.springframework.test.util.ReflectionTestUtils;

/**
* AppleSignInService는 OkHttpClient/JWKS 서명 검증기를 메서드 내부에서 직접 생성해서
* (의존성 주입이 안 되어 있어서) 실제 애플 서버 통신 없이는 getSocialInfo()의
* 정상 경로(서명 검증 성공)를 순수 단위 테스트로 재현할 수 없다.
* 네트워크 없이 검증 가능한 두 가지 — P8 키 파싱, 잘못된 idToken 형식 — 만 다룬다.
*/
class AppleSignInServiceTest {

private String base64EncodedEcPrivateKey() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair keyPair = generator.generateKeyPair();
return Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
}

@Test
void 유효한_EC_비밀키면_정상적으로_파싱된다() throws Exception {
AppleSignInService service = new AppleSignInService();

ReflectionTestUtils.invokeMethod(service, "getPrivateKey", base64EncodedEcPrivateKey());

assertThat(ReflectionTestUtils.getField(service, "PRIVATE_KEY")).isNotNull();
}

@Test
void 잘못된_형식의_비밀키면_UnauthorizedException() {
AppleSignInService service = new AppleSignInService();

assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "getPrivateKey", "not-a-valid-key"))
.isInstanceOf(UnauthorizedException.class);
}

@Test
void idToken이_JWT_형식이_아니면_UnauthorizedException() {
AppleSignInService service = new AppleSignInService();

assertThatThrownBy(() -> service.getSocialInfo("this-is-not-a-jwt"))
.isInstanceOf(UnauthorizedException.class);
}
}
Loading
Loading