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
Expand Up @@ -25,6 +25,7 @@ public enum ErrorCode implements ResponseCode {
AUTH_INVALID_LOGIN_TOKEN_KEY(HttpStatus.UNAUTHORIZED, 40106, "유효하지 않은 로그인 토큰 키입니다."),
AUTH_BLACKLIST_TOKEN(HttpStatus.UNAUTHORIZED, 40107, "블랙리스트에 등록된 토큰입니다."),
AUTH_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 40108, "인증 처리 중 서버 오류가 발생했습니다."),
AUTH_APPLE_IDENTITY_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, 40109, "유효하지 않은 Apple identity token입니다."),

JSON_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 50100, "JSON 직렬화/역직렬화에 실패했습니다."),
AWS_BUCKET_BASE_URL_NOT_CONFIGURED(HttpStatus.INTERNAL_SERVER_ERROR, 50101, "aws s3 bucket base url 설정이 누락되었습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public enum AuthParameters {
JWT_PREFIX("Bearer "),
KAKAO("kakao"),
GOOGLE("google"),
APPLE("apple"),
KAKAO_PROVIDER_ID_KEY("id"),
GOOGLE_PROVIDER_ID_KEY("sub"),
JWT_ACCESS_TOKEN_KEY("userId"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public enum SecurityWhitelist {
ACTUATOR_PROMETHEUS("/actuator/prometheus"),
AUTH_USERS("/auth/users"),
AUTH_TOKEN("/auth/token"),
AUTH_APPLE("/auth/apple"),
API_TEST("/api/test/**"),
AUTH_EXCHANGE_TEMP_TOKEN("/auth/exchange-temp-token"),
AUTH_SET_COOKIE("/auth/set-cookie");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package konkuk.thip.common.security.oauth2.apple;

import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.security.KeyFactory;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Date;

@Component
@RequiredArgsConstructor
public class AppleClientSecretGenerator {

private static final String APPLE_AUDIENCE = "https://appleid.apple.com";
private static final long EXPIRATION_MS = 1000L * 60 * 60 * 24 * 180; // 180일

private final AppleProperties appleProperties;

public String generate() {
try {
ECPrivateKey privateKey = loadPrivateKey(appleProperties.getPrivateKey());

return Jwts.builder()
.header().add("kid", appleProperties.getKeyId()).and()
.issuer(appleProperties.getTeamId())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
.audience().add(APPLE_AUDIENCE).and()
.subject(appleProperties.getClientId())
.signWith(privateKey, Jwts.SIG.ES256)
.compact();
} catch (Exception e) {
throw new IllegalStateException("Apple client_secret 생성 실패", e);
}
}

private ECPrivateKey loadPrivateKey(String privateKeyStr) throws Exception {
String stripped = privateKeyStr
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(stripped);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
return (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(spec);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package konkuk.thip.common.security.oauth2.apple;

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 konkuk.thip.common.exception.AuthException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.net.URL;

import static konkuk.thip.common.exception.code.ErrorCode.AUTH_APPLE_IDENTITY_TOKEN_INVALID;

@Slf4j
@Component
public class AppleIdentityTokenVerifier {

private static final String APPLE_JWK_SET_URI = "https://appleid.apple.com/auth/keys";

private final ConfigurableJWTProcessor<SecurityContext> jwtProcessor;

public AppleIdentityTokenVerifier() {
try {
JWKSource<SecurityContext> keySource = new RemoteJWKSet<>(new URL(APPLE_JWK_SET_URI));
jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource));
} catch (Exception e) {
throw new IllegalStateException("Apple JWK 초기화 실패", e);
}
}

public String verify(String identityToken) {
try {
JWTClaimsSet claims = jwtProcessor.process(identityToken, null);
String sub = claims.getSubject();
log.info("[Apple Login] verified sub={}", sub);
Comment on lines +40 to +41

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 | ⚡ Quick win

Apple sub를 INFO 로그에 기록하지 마세요.

sub는 사용자를 지속적으로 식별하는 값입니다. 로그인 성공 여부만 기록하거나 요청 상관관계 ID를 사용하세요.

🤖 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/konkuk/thip/common/security/oauth2/apple/AppleIdentityTokenVerifier.java`
around lines 40 - 41, AppleIdentityTokenVerifier의 sub 로깅을 제거하세요.
claims.getSubject()로 값을 얻는 로직은 유지하되, 로그인 성공 여부나 요청 상관관계 ID만 기록하도록 log.info 호출을
수정하세요.

return sub;
Comment on lines +27 to +42

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 | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

리포지터리에서 사용하는 Nimbus JOSE+JWT 버전의 DefaultJWTProcessor로 Apple ID token의 issuer와 aud(client ID)를 검증하는 권장 설정은 무엇인가요?

💡 Result:

Apple ID 토큰을 검증하기 위해 Nimbus JOSE+JWT의 DefaultJWTProcessor를 사용할 때, issuer("iss")와 audience("aud")를 검증하는 권장 방법은 DefaultJWTClaimsVerifier를 설정하여 DefaultJWTProcessor에 주입하는 것입니다 [1][2][3]. 애플의 공식 요구사항에 따라, iss 클레임은 반드시 https://appleid.apple.com 이어야 하며, aud 클레임은 개발자의 client_id와 일치해야 합니다 [4][5]. 권장 구현 설정 예시는 다음과 같습니다: 1. JWTClaimsSet을 사용하여 필수적인 iss와 aud 값을 설정합니다 [1][3]. 2. DefaultJWTClaimsVerifier를 생성하고, 이 verifier를 JWTProcessor에 설정합니다 [1][6][7]. // 1. 검증할 정확한 클레임 값 설정 (iss, aud) JWTClaimsSet expectedClaims = new JWTClaimsSet.Builder.issuer("https://appleid.apple.com").audience("YOUR_CLIENT_ID") // 애플의 client_id.build; // 2. 검증기 생성 (필요에 따라 "exp"와 같은 필수 클레임도 함께 명시) DefaultJWTClaimsVerifier<?> claimsVerifier = new DefaultJWTClaimsVerifier<>( expectedClaims, new HashSet<>(Arrays.asList("exp", "sub", "iat")) // 필수 포함 클레임 명시); // 3. 프로세서에 설정 ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>; jwtProcessor.setJWTClaimsSetVerifier(claimsVerifier); 또한, 토큰의 서명을 검증하기 위해서는 Apple의 공개 키(JWKSet)를 사용하여 JWSKeySelector를 설정해야 합니다 [8][9]. 추가적으로 토큰의 typ 헤더를 명시적으로 검증하는 것이 보안상 권장됩니다 [2][9].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== AppleIdentityTokenVerifier ==\n'
sed -n '1,220p' src/main/java/konkuk/thip/common/security/oauth2/apple/AppleIdentityTokenVerifier.java

printf '\n== Search verifier usage ==\n'
rg -n "AppleIdentityTokenVerifier|appleLogin|identityToken|clientId|aud|issuer|iss|sub" src/main/java -g '!**/target/**'

Repository: THIP-TextHip/THIP-Server

Length of output: 8373


issaud를 함께 검증하세요
지금은 Apple JWK/RS256 서명만 확인해서, 다른 clientId용으로 발급된 유효한 Apple 토큰도 통과할 수 있습니다. https://appleid.apple.com issuer와 설정된 clientId audience를 서명 검증 뒤에 강제하고, sub가 비어 있지 않은지도 확인하세요.

🤖 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/konkuk/thip/common/security/oauth2/apple/AppleIdentityTokenVerifier.java`
around lines 27 - 42, Update AppleIdentityTokenVerifier.verify to enforce the
issuer https://appleid.apple.com and the configured clientId audience after
signature verification, and reject tokens whose subject is null or empty before
logging or returning it. Reuse the existing configuration symbol for clientId
and preserve the current verification failure handling.

} catch (Exception e) {
log.warn("[Apple Login] identity token verification failed: {}", e.getMessage());
throw new AuthException(AUTH_APPLE_IDENTITY_TOKEN_INVALID);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package konkuk.thip.common.security.oauth2.apple;

import jakarta.validation.constraints.NotBlank;

public record AppleLoginRequest(
@NotBlank String identityToken,
String authorizationCode
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package konkuk.thip.common.security.oauth2.apple;

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 privateKey;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package konkuk.thip.common.security.oauth2.apple;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;

@Component
@RequiredArgsConstructor
public class AppleRefreshTokenStore {

private static final String PREFIX = "apple:refresh:";
private static final Duration TTL = Duration.ofMinutes(30);

private final RedisTemplate<String, String> redisTemplate;

public void save(String oauth2Id, String refreshToken) {
redisTemplate.opsForValue().set(PREFIX + oauth2Id, refreshToken, TTL);
}

public String pop(String oauth2Id) {
String key = PREFIX + oauth2Id;
String token = redisTemplate.opsForValue().get(key);
if (token != null) {
redisTemplate.delete(key);
}
return token;
Comment on lines +22 to +28

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 | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

리포지터리의 Spring Data Redis 버전에서 ValueOperations#getAndDelete가 Redis GETDEL로 원자적으로 실행되는지 확인해 주세요.

💡 Result:

네, Spring Data Redis의 ValueOperations#getAndDelete 메서드는 Redis의 GETDEL 명령어를 사용하여 원자적으로(atomically) 실행됩니다 [1][2]. 구체적인 근거는 다음과 같습니다: 1. 구현 확인: Spring Data Redis의 소스 코드(DefaultValueOperations.java)를 확인하면, getAndDelete 메서드는 내부적으로 RedisConnection의 getDel 명령을 호출하도록 구현되어 있습니다 [1]. 2. Redis 명령 특성: Redis의 GETDEL 명령어는 키의 값을 가져온 후 즉시 해당 키를 삭제하는 동작을 하나의 원자적 작업으로 수행합니다 [3]. 3. 공식 문서 및 API: Spring Data Redis의 ValueOperations API 문서에서도 getAndDelete 메서드의 참고 항목(See Also)으로 Redis의 GETDEL 명령어를 명시하고 있으며, 해당 명령어를 통해 원자적 삭제를 지원함을 나타냅니다 [4][5][2]. 따라서 ValueOperations#getAndDelete를 사용하면 별도의 트랜잭션이나 Lua 스크립트 없이도 안전하게 값을 읽고 삭제하는 원자적 처리가 가능합니다.

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Spring Data Redis version candidates =="
rg -n --hidden -S "spring-data-redis|springframework.data.redis" -g 'pom.xml' -g 'build.gradle' -g 'build.gradle.kts' -g 'gradle.properties' -g 'settings.gradle' -g 'settings.gradle.kts' .

echo
echo "== AppleRefreshTokenStore =="
sed -n '1,120p' src/main/java/konkuk/thip/common/security/oauth2/apple/AppleRefreshTokenStore.java

echo
echo "== Files mentioning getAndDelete / GETDEL =="
rg -n -S "getAndDelete|GETDEL|getDel" src/main/java .

Repository: THIP-TextHip/THIP-Server

Length of output: 205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Build files =="
git ls-files '*pom.xml' '*build.gradle' '*build.gradle.kts' '*settings.gradle' '*settings.gradle.kts' '*gradle.properties'

echo
echo "== AppleRefreshTokenStore =="
sed -n '1,160p' src/main/java/konkuk/thip/common/security/oauth2/apple/AppleRefreshTokenStore.java

echo
echo "== Search for getAndDelete / getDel / RedisTemplate delete-get patterns =="
rg -n -S "getAndDelete|getDel|opsForValue\(\)\.get\(|redisTemplate\.delete\(" src/main/java

Repository: THIP-TextHip/THIP-Server

Length of output: 1907


임시 refresh token 소비를 원자적으로 바꾸세요. pop에서 GETDELETE를 분리하면 동시 요청이 같은 토큰을 둘 다 가져갈 수 있습니다. ValueOperations#getAndDelete로 교체해 단일 소비를 보장하세요.

🤖 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/konkuk/thip/common/security/oauth2/apple/AppleRefreshTokenStore.java`
around lines 22 - 28, Update AppleRefreshTokenStore.pop to replace the separate
redisTemplate.opsForValue().get and conditional delete calls with a single
ValueOperations#getAndDelete operation, preserving the existing key construction
and return value while ensuring each temporary refresh token is consumed only
once.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package konkuk.thip.common.security.oauth2.apple;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.util.Map;

@Slf4j
@Component
@RequiredArgsConstructor
public class AppleTokenClient {

private static final String APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
private static final String APPLE_REVOKE_URL = "https://appleid.apple.com/auth/revoke";

private final RestTemplate restTemplate;
private final AppleProperties appleProperties;
private final AppleClientSecretGenerator clientSecretGenerator;

public String exchangeAuthorizationCode(String authorizationCode) {
String clientSecret = clientSecretGenerator.generate();

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", appleProperties.getClientId());
params.add("client_secret", clientSecret);
params.add("code", authorizationCode);
params.add("grant_type", "authorization_code");

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

try {
Map<?, ?> response = restTemplate.postForObject(
APPLE_TOKEN_URL,
new HttpEntity<>(params, headers),
Map.class
);
if (response == null || !response.containsKey("refresh_token")) {
log.warn("[Apple] authorizationCode 교환 실패: refresh_token 없음");
return null;
}
return (String) response.get("refresh_token");
} catch (Exception e) {
log.warn("[Apple] authorizationCode 교환 중 오류: {}", e.getMessage());
return null;
}
Comment on lines +39 to +53

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

Apple 토큰 작업 실패를 무시하지 마세요.

코드 교환 실패 시 로그인은 refresh token 없이 계속되고, 철회 실패 시 계정은 삭제되어 재시도할 근거가 사라집니다. 제공된 authorization code 교환 실패는 호출자에게 실패로 전파하고, 철회는 삭제 전에 성공을 보장하거나 내구성 있는 재시도 작업으로 저장하세요.

Also applies to: 68-73

🤖 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/konkuk/thip/common/security/oauth2/apple/AppleTokenClient.java`
around lines 39 - 53, Update the Apple token exchange method around the
restTemplate.postForObject call to propagate missing refresh_token responses and
caught exceptions to the caller instead of logging and returning null. Update
the related revocation flow around the additional affected block so account
deletion proceeds only after revocation succeeds, or persist a durable retry
task when revocation fails.

}

public void revokeToken(String refreshToken) {
String clientSecret = clientSecretGenerator.generate();

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", appleProperties.getClientId());
params.add("client_secret", clientSecret);
params.add("token", refreshToken);
params.add("token_type_hint", "refresh_token");

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

try {
restTemplate.postForObject(APPLE_REVOKE_URL, new HttpEntity<>(params, headers), Void.class);
log.info("[Apple] 토큰 철회 완료");
} catch (Exception e) {
log.warn("[Apple] 토큰 철회 중 오류: {}", e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,23 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import konkuk.thip.common.dto.BaseResponse;
import konkuk.thip.common.exception.AuthException;
import konkuk.thip.common.exception.BusinessException;
import konkuk.thip.common.exception.code.ErrorCode;
import konkuk.thip.common.security.annotation.Oauth2Id;
import konkuk.thip.common.security.oauth2.apple.AppleIdentityTokenVerifier;
import konkuk.thip.common.security.oauth2.apple.AppleLoginRequest;
import konkuk.thip.common.security.oauth2.apple.AppleRefreshTokenStore;
import konkuk.thip.common.security.oauth2.apple.AppleTokenClient;
import konkuk.thip.common.security.oauth2.tokenstorage.LoginTokenStorage;
import konkuk.thip.common.security.oauth2.auth.dto.AuthSetCookieRequest;
import konkuk.thip.common.security.oauth2.auth.dto.AuthSetCookieResponse;
import konkuk.thip.common.security.oauth2.auth.dto.AuthTokenRequest;
import konkuk.thip.common.security.oauth2.auth.dto.AuthTokenResponse;
import konkuk.thip.common.security.util.JwtUtil;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
Expand All @@ -29,6 +35,7 @@
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;

import static konkuk.thip.common.exception.code.ErrorCode.API_INVALID_PARAM;
import static konkuk.thip.common.exception.code.ErrorCode.AUTH_INVALID_LOGIN_TOKEN_KEY;
Expand All @@ -45,8 +52,10 @@ public class AuthController {

private final UserJpaRepository userJpaRepository;
private final JwtUtil jwtUtil;

private final LoginTokenStorage loginTokenStorage;
private final AppleIdentityTokenVerifier appleIdentityTokenVerifier;
private final AppleTokenClient appleTokenClient;
private final AppleRefreshTokenStore appleRefreshTokenStore;

@Operation(
summary = "소셜 로그인 유저 확인",
Expand All @@ -70,6 +79,44 @@ public BaseResponse<AuthTokenResponse> checkUserExists(
});
}

@Operation(
summary = "Apple 소셜 로그인 (iOS 네이티브)",
description = "iOS에서 Apple Sign In SDK로 받은 identityToken을 검증하여 AccessToken 또는 SignupToken을 발급합니다."
)
@PostMapping("/apple")
public BaseResponse<AuthTokenResponse> appleLogin(
@Valid @RequestBody AppleLoginRequest request
) {
String appleUserId = appleIdentityTokenVerifier.verify(request.identityToken());
String oauth2Id = "apple_" + appleUserId;

Optional<UserJpaEntity> existingUser = userJpaRepository.findByOauth2Id(oauth2Id);

if (request.authorizationCode() != null) {
String refreshToken = appleTokenClient.exchangeAuthorizationCode(request.authorizationCode());
if (refreshToken != null) {
if (existingUser.isPresent()) {
// 기존 유저: DB에 바로 저장
existingUser.get().updateAppleRefreshToken(refreshToken);
userJpaRepository.save(existingUser.get());
} else {
// 신규 유저: 회원가입 완료 시 옮겨 저장하도록 Redis에 임시 보관 (TTL 30분)
appleRefreshTokenStore.save(oauth2Id, refreshToken);
}
}
Comment on lines +95 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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

authorization code 교환 실패를 로그인 성공으로 처리하지 마세요.

exchangeAuthorizationCode()는 통신 오류나 refresh_token 누락 시 null을 반환하지만, 현재는 신규 사용자에게도 SignupToken을 발급합니다. 이후 가입해도 저장·철회할 Apple refresh token이 없어집니다. authorizationCode가 전달된 경우 교환 실패를 인증 오류로 반환하거나, 재시도 가능한 영속 상태를 남기세요.

🤖 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/konkuk/thip/common/security/oauth2/auth/AuthController.java`
around lines 95 - 106, Update the authorizationCode flow in AuthController so a
null result from exchangeAuthorizationCode is not treated as successful
authentication or followed by SignupToken issuance. Return an authentication
error for exchange failure, or persist a retryable state that preserves the
pending authorization safely; ensure this applies to both existing and new
users.

}

return existingUser
.map(user -> {
String accessToken = jwtUtil.createAccessToken(user.getUserId());
return BaseResponse.ok(AuthTokenResponse.of(accessToken, false));
})
.orElseGet(() -> {
String tempToken = jwtUtil.createSignupToken(oauth2Id);
return BaseResponse.ok(AuthTokenResponse.of(tempToken, true));
});
}

@Operation(
summary = "로그인 토큰 키로 토큰 발급",
description = "로그인 토큰 키를 사용하여 AccessToken 또는 SignupToken을 발급합니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public class UserJpaEntity extends BaseJpaEntity {
@Column(name = "oauth2_id", length = 50, nullable = false)
private String oauth2Id;

@Column(name = "apple_refresh_token", length = 1000)
private String appleRefreshToken;
Comment on lines +42 to +43

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

Apple refresh token을 평문으로 저장하지 마세요.

Apple refresh token은 장기 bearer credential인데, 현재 JPA 필드와 DB 컬럼이 원문을 그대로 보관합니다. DB 덤프나 읽기 권한 탈취 시 Apple 연결이 재사용될 수 있습니다. 애플리케이션 레벨 암호화(envelope encryption 등)를 적용하고 revoke 시점에만 복호화하세요.

  • src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java#L42-L43: 암·복호화되는 값 객체 또는 JPA converter로 원문 token 저장을 제거하세요.
  • src/main/resources/db/migration/V260722__Add_apple_refresh_token.sql#L1-L2: 암호문 저장 형식과 길이에 맞는 컬럼으로 변경하세요.
📍 Affects 2 files
  • src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java#L42-L43 (this comment)
  • src/main/resources/db/migration/V260722__Add_apple_refresh_token.sql#L1-L2
🤖 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/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java` around
lines 42 - 43,
src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java:42-43에서
appleRefreshToken을 평문 String으로 저장하지 않도록 암·복호화를 담당하는 값 객체 또는 JPA converter를 적용하고,
복호화는 revoke 처리 시점에만 수행하세요.
src/main/resources/db/migration/V260722__Add_apple_refresh_token.sql:1-2에서는 해당
암호문 형식과 암호화된 값의 길이를 수용하도록 컬럼 타입 또는 길이를 변경하세요.


/**
* -- SETTER --
* 회원 탈퇴용
Expand Down Expand Up @@ -76,6 +79,10 @@ public void updateFrom(User user) {
this.recordReviewCount = user.getRecordReviewCount();
}

public void updateAppleRefreshToken(String token) {
this.appleRefreshToken = token;
}

public void softDelete(User user) {
if(this.status.equals(INACTIVE)){
throw new InvalidStateException(USER_ALREADY_DELETED);
Expand Down
Loading
Loading