-
Notifications
You must be signed in to change notification settings - Fork 1
[feat] 애플 소셜로그인(IOS) 추가 #367
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
Changes from all commits
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 |
|---|---|---|
| @@ -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); | ||
| return sub; | ||
|
Comment on lines
+27
to
+42
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. 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 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
🤖 Prompt for AI Agents |
||
| } 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
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. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 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/javaRepository: THIP-TextHip/THIP-Server Length of output: 1907 임시 refresh token 소비를 원자적으로 바꾸세요. 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| 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
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. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift Apple 토큰 작업 실패를 무시하지 마세요. 코드 교환 실패 시 로그인은 refresh token 없이 계속되고, 철회 실패 시 계정은 삭제되어 재시도할 근거가 사라집니다. 제공된 authorization code 교환 실패는 호출자에게 실패로 전파하고, 철회는 삭제 전에 성공을 보장하거나 내구성 있는 재시도 작업으로 저장하세요. Also applies to: 68-73 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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 |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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; | ||
|
|
@@ -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 = "소셜 로그인 유저 확인", | ||
|
|
@@ -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
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. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win authorization code 교환 실패를 로그인 성공으로 처리하지 마세요.
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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을 발급합니다." | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
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. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift Apple refresh token을 평문으로 저장하지 마세요. Apple refresh token은 장기 bearer credential인데, 현재 JPA 필드와 DB 컬럼이 원문을 그대로 보관합니다. DB 덤프나 읽기 권한 탈취 시 Apple 연결이 재사용될 수 있습니다. 애플리케이션 레벨 암호화(envelope encryption 등)를 적용하고 revoke 시점에만 복호화하세요.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * -- SETTER -- | ||
| * 회원 탈퇴용 | ||
|
|
@@ -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); | ||
|
|
||
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.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Apple
sub를 INFO 로그에 기록하지 마세요.sub는 사용자를 지속적으로 식별하는 값입니다. 로그인 성공 여부만 기록하거나 요청 상관관계 ID를 사용하세요.🤖 Prompt for AI Agents