Skip to content

Commit 960e06e

Browse files
authored
Merge pull request #259 from FunD-StockProject/fix/social-login-providerId
Fix: 소셜 로그인 providerId를 식별자로 사용하도록 변경 및 애플 이메일 없는 경우 예외 처리
2 parents 28d5062 + 1949ae8 commit 960e06e

12 files changed

+447
-56
lines changed
1.78 KB
Binary file not shown.
324 Bytes
Binary file not shown.
1.78 KB
Binary file not shown.
652 Bytes
Binary file not shown.

src/main/java/com/fund/stockProject/auth/controller/AuthController.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.springframework.web.bind.annotation.*;
2222
import org.springframework.web.multipart.MultipartFile;
2323
import jakarta.validation.Valid;
24+
import jakarta.servlet.http.HttpServletRequest;
2425

2526
import java.util.Map;
2627

@@ -42,13 +43,17 @@ public class AuthController {
4243
})
4344
public ResponseEntity<Map<String, String>> register(
4445
@Parameter(description = "소셜 회원가입 요청 DTO", required = true)
45-
@ModelAttribute OAuth2RegisterRequest request) {
46+
@ModelAttribute OAuth2RegisterRequest request,
47+
HttpServletRequest httpRequest) {
4648
try {
47-
String imageUrl = authService.register(request);
49+
String imageUrl = authService.register(request, buildClientKey(httpRequest));
4850
return ResponseEntity.ok(Map.of(
4951
"message", "User registered successfully",
5052
"profileImageUrl", imageUrl != null ? imageUrl : ""
5153
));
54+
} catch (IllegalArgumentException e) {
55+
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
56+
.body(Map.of("message", e.getMessage()));
5257
} catch (Exception e) {
5358
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
5459
.body(Map.of("message", "Failed to complete social registration: " + e.getMessage()));
@@ -236,4 +241,17 @@ public ResponseEntity<Map<String, Boolean>> checkEmailDuplicate(
236241
boolean isDuplicate = authService.isEmailDuplicate(email);
237242
return ResponseEntity.ok(Map.of("duplicate", isDuplicate));
238243
}
244+
245+
private String buildClientKey(HttpServletRequest request) {
246+
String forwardedFor = request.getHeader("X-Forwarded-For");
247+
String ip;
248+
if (forwardedFor != null && !forwardedFor.isBlank()) {
249+
String[] parts = forwardedFor.split(",");
250+
ip = parts.length > 0 ? parts[0].trim() : request.getRemoteAddr();
251+
} else {
252+
ip = request.getRemoteAddr();
253+
}
254+
String userAgent = request.getHeader("User-Agent");
255+
return (ip == null ? "" : ip.trim()) + "|" + (userAgent == null ? "" : userAgent.trim());
256+
}
239257
}

src/main/java/com/fund/stockProject/auth/controller/OAuth2Controller.java

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.swagger.v3.oas.annotations.media.Content;
2121
import io.swagger.v3.oas.annotations.media.Schema;
2222
import io.swagger.v3.oas.annotations.Parameter;
23+
import jakarta.servlet.http.HttpServletRequest;
2324

2425
@Slf4j
2526
@RestController
@@ -119,10 +120,11 @@ public ResponseEntity<LoginResponse> googleLogin(
119120
})
120121
public ResponseEntity<LoginResponse> appleLogin(
121122
@Parameter(description = "인가 코드", example = "c1d2e3f4...") @RequestParam String code,
122-
@Parameter(description = "redirect uri", example = "http://localhost:5173/login/oauth2/code/apple") @RequestParam String state) {
123+
@Parameter(description = "redirect uri", example = "http://localhost:5173/login/oauth2/code/apple") @RequestParam String state,
124+
HttpServletRequest request) {
123125
try {
124126
log.info("Apple login attempt - state: {}", state);
125-
LoginResponse response = oAuth2Service.appleLogin(code, state);
127+
LoginResponse response = oAuth2Service.appleLogin(code, state, null, buildClientKey(request));
126128

127129
if ("NEED_REGISTER".equals(response.getState())) {
128130
log.info("Apple login - registration required");
@@ -149,15 +151,16 @@ public ResponseEntity<LoginResponse> appleLoginFormPost(
149151
@Parameter(description = "redirect uri", example = "http://localhost:5173/login/oauth2/code/apple") @RequestParam String state,
150152
@Parameter(description = "user") @RequestParam(required = false) String user,
151153
@Parameter(description = "error", example = "invalid_request") @RequestParam(required = false) String error,
152-
@Parameter(description = "error_description") @RequestParam(required = false, name = "error_description") String errorDescription) {
154+
@Parameter(description = "error_description") @RequestParam(required = false, name = "error_description") String errorDescription,
155+
HttpServletRequest request) {
153156
if (error != null && !error.isBlank()) {
154157
log.warn("Apple login form_post error: {} - {}", error, errorDescription);
155158
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
156159
}
157160

158161
try {
159162
log.info("Apple login (form_post) attempt - state: {}", state);
160-
LoginResponse response = oAuth2Service.appleLogin(code, state, user);
163+
LoginResponse response = oAuth2Service.appleLogin(code, state, user, buildClientKey(request));
161164

162165
if ("NEED_REGISTER".equals(response.getState())) {
163166
log.info("Apple login (form_post) - registration required");
@@ -172,4 +175,17 @@ public ResponseEntity<LoginResponse> appleLoginFormPost(
172175
}
173176
}
174177

178+
private String buildClientKey(HttpServletRequest request) {
179+
String forwardedFor = request.getHeader("X-Forwarded-For");
180+
String ip;
181+
if (forwardedFor != null && !forwardedFor.isBlank()) {
182+
String[] parts = forwardedFor.split(",");
183+
ip = parts.length > 0 ? parts[0].trim() : request.getRemoteAddr();
184+
} else {
185+
ip = request.getRemoteAddr();
186+
}
187+
String userAgent = request.getHeader("User-Agent");
188+
return (ip == null ? "" : ip.trim()) + "|" + (userAgent == null ? "" : userAgent.trim());
189+
}
190+
175191
}

src/main/java/com/fund/stockProject/auth/dto/OAuth2RegisterRequest.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@
1414
public class OAuth2RegisterRequest {
1515
@Schema(description = "사용자 닉네임", example = "human123", requiredMode = Schema.RequiredMode.REQUIRED)
1616
private String nickname;
17-
@Schema(description = "사용자 이메일", example = "user@example.com", requiredMode = Schema.RequiredMode.REQUIRED)
17+
@Schema(
18+
description = "사용자 이메일 (Apple 최초 로그인 등 제공되지 않는 경우 생략 가능)",
19+
example = "user@example.com",
20+
requiredMode = Schema.RequiredMode.NOT_REQUIRED
21+
)
1822
private String email;
1923
@Schema(description = "OAuth2 제공자", example = "KAKAO", allowableValues = {"KAKAO","NAVER","GOOGLE","APPLE"}, requiredMode = Schema.RequiredMode.REQUIRED)
2024
private PROVIDER provider;
25+
@Schema(description = "OAuth2 제공자의 고유 사용자 ID(예: Apple sub)", example = "001234.abcd5678efgh", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
26+
private String providerId;
2127
@Schema(description = "생년월일 (yyyy-MM-dd)", example = "1993-08-15")
2228
@DateTimeFormat(pattern = "yyyy-MM-dd")
2329
private LocalDate birthDate;
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package com.fund.stockProject.auth.service;
2+
3+
import com.fund.stockProject.auth.domain.PROVIDER;
4+
import org.springframework.stereotype.Service;
5+
6+
import java.time.Duration;
7+
import java.util.Optional;
8+
import java.util.concurrent.ConcurrentHashMap;
9+
import java.util.concurrent.ConcurrentMap;
10+
11+
@Service
12+
public class AppleLoginContextService {
13+
14+
private static final String FALLBACK_PREFIX = "apple_";
15+
private static final String FALLBACK_DOMAIN = "@apple.local";
16+
private static final long PENDING_TTL_MILLIS = Duration.ofMinutes(30).toMillis();
17+
18+
private final ConcurrentMap<String, PendingProviderId> pendingProviderIdsByEmail = new ConcurrentHashMap<>();
19+
private final ConcurrentMap<String, PendingProviderId> pendingProviderIdsByClient = new ConcurrentHashMap<>();
20+
21+
public void savePendingProviderId(PROVIDER provider, String email, String providerId) {
22+
String normalizedEmail = normalizeEmail(email);
23+
String scopedKey = buildScopedKey(provider, normalizedEmail);
24+
if (scopedKey == null || isBlank(providerId)) {
25+
return;
26+
}
27+
cleanupExpiredIfNeeded();
28+
pendingProviderIdsByEmail.put(
29+
scopedKey,
30+
new PendingProviderId(providerId.trim(), System.currentTimeMillis() + PENDING_TTL_MILLIS)
31+
);
32+
}
33+
34+
public Optional<String> consumePendingProviderId(PROVIDER provider, String email) {
35+
String normalizedEmail = normalizeEmail(email);
36+
String scopedKey = buildScopedKey(provider, normalizedEmail);
37+
if (scopedKey == null) {
38+
return Optional.empty();
39+
}
40+
41+
PendingProviderId pending = pendingProviderIdsByEmail.remove(scopedKey);
42+
if (pending == null || pending.expiresAtMillis < System.currentTimeMillis()) {
43+
return Optional.empty();
44+
}
45+
return Optional.of(pending.providerId);
46+
}
47+
48+
public void savePendingProviderIdByClient(PROVIDER provider, String clientKey, String providerId) {
49+
String normalizedClientKey = normalizeClientKey(clientKey);
50+
String scopedKey = buildScopedKey(provider, normalizedClientKey);
51+
if (scopedKey == null || isBlank(providerId)) {
52+
return;
53+
}
54+
cleanupExpiredIfNeeded();
55+
pendingProviderIdsByClient.put(
56+
scopedKey,
57+
new PendingProviderId(providerId.trim(), System.currentTimeMillis() + PENDING_TTL_MILLIS)
58+
);
59+
}
60+
61+
public Optional<String> consumePendingProviderIdByClient(PROVIDER provider, String clientKey) {
62+
String normalizedClientKey = normalizeClientKey(clientKey);
63+
String scopedKey = buildScopedKey(provider, normalizedClientKey);
64+
if (scopedKey == null) {
65+
return Optional.empty();
66+
}
67+
68+
PendingProviderId pending = pendingProviderIdsByClient.remove(scopedKey);
69+
if (pending == null || pending.expiresAtMillis < System.currentTimeMillis()) {
70+
return Optional.empty();
71+
}
72+
return Optional.of(pending.providerId);
73+
}
74+
75+
public String buildFallbackEmail(String providerId) {
76+
if (isBlank(providerId)) {
77+
throw new IllegalArgumentException("Apple providerId is required to build fallback email");
78+
}
79+
return FALLBACK_PREFIX + encodeHex(providerId.trim()) + FALLBACK_DOMAIN;
80+
}
81+
82+
public Optional<String> extractProviderIdFromFallbackEmail(String email) {
83+
String normalizedEmail = normalizeEmail(email);
84+
if (normalizedEmail == null || !normalizedEmail.endsWith(FALLBACK_DOMAIN)) {
85+
return Optional.empty();
86+
}
87+
String localPart = normalizedEmail.substring(0, normalizedEmail.length() - FALLBACK_DOMAIN.length());
88+
if (!localPart.startsWith(FALLBACK_PREFIX)) {
89+
return Optional.empty();
90+
}
91+
92+
String encoded = localPart.substring(FALLBACK_PREFIX.length());
93+
if (encoded.isEmpty() || encoded.length() % 2 != 0) {
94+
return Optional.empty();
95+
}
96+
97+
try {
98+
return Optional.of(decodeHex(encoded));
99+
} catch (IllegalArgumentException e) {
100+
return Optional.empty();
101+
}
102+
}
103+
104+
private void cleanupExpiredIfNeeded() {
105+
if (pendingProviderIdsByEmail.size() + pendingProviderIdsByClient.size() < 1000) {
106+
return;
107+
}
108+
long now = System.currentTimeMillis();
109+
pendingProviderIdsByEmail.entrySet().removeIf(entry -> entry.getValue().expiresAtMillis < now);
110+
pendingProviderIdsByClient.entrySet().removeIf(entry -> entry.getValue().expiresAtMillis < now);
111+
}
112+
113+
private String normalizeEmail(String email) {
114+
if (isBlank(email)) {
115+
return null;
116+
}
117+
return email.trim().toLowerCase(java.util.Locale.ROOT);
118+
}
119+
120+
private boolean isBlank(String value) {
121+
return value == null || value.trim().isEmpty();
122+
}
123+
124+
private String normalizeClientKey(String clientKey) {
125+
if (isBlank(clientKey)) {
126+
return null;
127+
}
128+
return clientKey.trim().toLowerCase(java.util.Locale.ROOT);
129+
}
130+
131+
private String buildScopedKey(PROVIDER provider, String key) {
132+
if (provider == null || key == null) {
133+
return null;
134+
}
135+
return provider.name() + "|" + key;
136+
}
137+
138+
private String encodeHex(String value) {
139+
byte[] bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8);
140+
StringBuilder sb = new StringBuilder(bytes.length * 2);
141+
for (byte b : bytes) {
142+
sb.append(String.format("%02x", b & 0xff));
143+
}
144+
return sb.toString();
145+
}
146+
147+
private String decodeHex(String hex) {
148+
int len = hex.length();
149+
byte[] data = new byte[len / 2];
150+
for (int i = 0; i < len; i += 2) {
151+
int high = Character.digit(hex.charAt(i), 16);
152+
int low = Character.digit(hex.charAt(i + 1), 16);
153+
if (high < 0 || low < 0) {
154+
throw new IllegalArgumentException("Invalid hex string");
155+
}
156+
data[i / 2] = (byte) ((high << 4) + low);
157+
}
158+
return new String(data, java.nio.charset.StandardCharsets.UTF_8);
159+
}
160+
161+
private record PendingProviderId(String providerId, long expiresAtMillis) {
162+
}
163+
}

src/main/java/com/fund/stockProject/auth/service/AuthService.java

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import org.springframework.transaction.annotation.Transactional;
2525
import org.springframework.web.multipart.MultipartFile;
2626

27+
import java.util.Optional;
28+
2729
import static com.fund.stockProject.auth.domain.ROLE.ROLE_USER;
2830

2931
@Service
@@ -40,6 +42,7 @@ public class AuthService {
4042
private final PasswordEncoder passwordEncoder;
4143
private final AuthenticationManager authenticationManager;
4244
private final TokenService tokenService;
45+
private final AppleLoginContextService appleLoginContextService;
4346

4447
/**
4548
* 현재 사용자가 인증된 상태인지 확인합니다.
@@ -109,19 +112,35 @@ public boolean isEmailDuplicate(String email) {
109112
}
110113

111114
@Transactional(rollbackFor = Exception.class)
112-
public String register(OAuth2RegisterRequest oAuth2RegisterRequest) {
115+
public String register(OAuth2RegisterRequest oAuth2RegisterRequest, String clientKey) {
113116
try {
117+
String nickname = normalizeToNull(oAuth2RegisterRequest.getNickname());
118+
if (nickname == null) {
119+
throw new IllegalArgumentException("Nickname is required.");
120+
}
121+
122+
PROVIDER provider = oAuth2RegisterRequest.getProvider();
123+
if (provider == null) {
124+
throw new IllegalArgumentException("Provider is required.");
125+
}
126+
114127
MultipartFile image = oAuth2RegisterRequest.getImage();
115128
String imageUrl = (image != null && !image.isEmpty()) ? s3Service.uploadUserImage(image, "users") : null;
129+
String providerId = resolveSocialProviderId(oAuth2RegisterRequest, provider, clientKey);
130+
String email = resolveRegistrationEmail(oAuth2RegisterRequest, provider, providerId);
131+
Boolean marketingAgreement = oAuth2RegisterRequest.getMarketingAgreement() != null
132+
? oAuth2RegisterRequest.getMarketingAgreement()
133+
: false;
116134

117135
User user = User.builder()
118-
.email(oAuth2RegisterRequest.getEmail())
119-
.nickname(oAuth2RegisterRequest.getNickname())
136+
.email(email)
137+
.nickname(nickname)
120138
.birthDate(oAuth2RegisterRequest.getBirthDate())
121-
.provider(oAuth2RegisterRequest.getProvider())
139+
.provider(provider)
140+
.providerId(providerId)
122141
.role(ROLE_USER)
123142
.isActive(true)
124-
.marketingAgreement(oAuth2RegisterRequest.getMarketingAgreement())
143+
.marketingAgreement(marketingAgreement)
125144
.profileImageUrl(imageUrl)
126145
.build();
127146

@@ -135,6 +154,54 @@ public String register(OAuth2RegisterRequest oAuth2RegisterRequest) {
135154
}
136155
}
137156

157+
private String resolveSocialProviderId(OAuth2RegisterRequest request, PROVIDER provider, String clientKey) {
158+
String requestProviderId = normalizeToNull(request.getProviderId());
159+
if (requestProviderId != null) {
160+
return requestProviderId;
161+
}
162+
163+
String email = normalizeToNull(request.getEmail());
164+
if (email != null) {
165+
Optional<String> pendingProviderId = appleLoginContextService.consumePendingProviderId(provider, email);
166+
if (provider == PROVIDER.APPLE) {
167+
return pendingProviderId
168+
.or(() -> appleLoginContextService.extractProviderIdFromFallbackEmail(email))
169+
.orElse(null);
170+
}
171+
return pendingProviderId.orElse(null);
172+
}
173+
174+
if (provider != PROVIDER.APPLE) {
175+
return null;
176+
}
177+
178+
return appleLoginContextService.consumePendingProviderIdByClient(PROVIDER.APPLE, clientKey).orElse(null);
179+
}
180+
181+
private String resolveRegistrationEmail(OAuth2RegisterRequest request, PROVIDER provider, String providerId) {
182+
String email = normalizeToNull(request.getEmail());
183+
if (email != null) {
184+
return email;
185+
}
186+
187+
if (provider == PROVIDER.APPLE) {
188+
if (providerId == null) {
189+
throw new IllegalArgumentException("Apple registration context expired. Please retry Apple login.");
190+
}
191+
return appleLoginContextService.buildFallbackEmail(providerId);
192+
}
193+
194+
throw new IllegalArgumentException("Email is required.");
195+
}
196+
197+
private String normalizeToNull(String value) {
198+
if (value == null) {
199+
return null;
200+
}
201+
String trimmed = value.trim();
202+
return trimmed.isEmpty() ? null : trimmed;
203+
}
204+
138205
/**
139206
* 일반 회원가입
140207
*/

0 commit comments

Comments
 (0)