Skip to content

Commit 4a7bd70

Browse files
authored
Merge pull request #260 from 2026-snapy/main
prod
2 parents 84a9be4 + a82f930 commit 4a7bd70

12 files changed

Lines changed: 418 additions & 46 deletions

File tree

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies {
3131
implementation 'org.springframework.boot:spring-boot-starter-web'
3232
implementation 'org.springframework.boot:spring-boot-starter-validation'
3333
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4'
34+
implementation 'com.google.api-client:google-api-client:2.9.0'
3435
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
3536
implementation 'io.awspring.cloud:spring-cloud-aws-s3:3.1.0'
3637
implementation 'com.eatthepath:pushy:0.15.4'

src/main/java/com/gbsw/snapy/domain/auth/controller/OAuthController.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.gbsw.snapy.domain.auth.controller;
22

33
import com.gbsw.snapy.domain.auth.dto.request.AppleIosLoginRequest;
4+
import com.gbsw.snapy.domain.auth.dto.request.GoogleAndroidLoginRequest;
45
import com.gbsw.snapy.domain.auth.dto.request.GoogleIosLoginRequest;
56
import com.gbsw.snapy.domain.auth.dto.response.LoginResponse;
67
import com.gbsw.snapy.domain.auth.dto.response.LoginServiceResult;
@@ -89,6 +90,18 @@ public ResponseEntity<ApiResponse<LoginResponse>> handleIosLogin(
8990
));
9091
}
9192

93+
// ── Android ──────────────────────────────────────────────────────────────────
94+
95+
@PostMapping("/api/auth/google/android")
96+
public ResponseEntity<ApiResponse<LoginResponse>> handleAndroidLogin(
97+
@Valid @RequestBody GoogleAndroidLoginRequest request
98+
) {
99+
LoginServiceResult result = googleOAuthService.processAndroidLogin(request.getIdToken());
100+
return ResponseEntity.ok(ApiResponse.success(
101+
new LoginResponse(result.accessToken(), result.refreshToken())
102+
));
103+
}
104+
92105
// ── Apple ────────────────────────────────────────────────────────────────────
93106

94107
@GetMapping("/auth/apple/login")

src/main/java/com/gbsw/snapy/domain/auth/dto/internal/GoogleUserInfo.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.gbsw.snapy.domain.auth.dto.internal;
22

33
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import lombok.AllArgsConstructor;
45
import lombok.Getter;
56
import lombok.NoArgsConstructor;
67

78
@Getter
89
@NoArgsConstructor
10+
@AllArgsConstructor
911
public class GoogleUserInfo {
1012

1113
private String sub;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.gbsw.snapy.domain.auth.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import lombok.Getter;
5+
import lombok.NoArgsConstructor;
6+
7+
@Getter
8+
@NoArgsConstructor
9+
public class GoogleAndroidLoginRequest {
10+
11+
@NotBlank
12+
private String idToken;
13+
}

src/main/java/com/gbsw/snapy/domain/auth/service/AppleOAuthService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ private User registerOAuthUser(AppleUserInfo userInfo, String fullName) {
105105
}
106106

107107
userRepository.findByEmail(userInfo.getEmail()).ifPresent(u -> {
108-
throw new CustomException(ErrorCode.APPLE_LOGIN_FAILED);
108+
throw new CustomException(ErrorCode.EMAIL_REGISTERED_WITH_DIFFERENT_PROVIDER);
109109
});
110110

111111
User user = User.builder()

src/main/java/com/gbsw/snapy/domain/auth/service/AuthService.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.gbsw.snapy.domain.auth.dto.response.LoginServiceResult;
66
import com.gbsw.snapy.domain.auth.dto.response.RefreshAccessTokenResponse;
77
import com.gbsw.snapy.domain.auth.dto.response.RegisterResponse;
8+
import com.gbsw.snapy.domain.auth.entity.OAuthProvider;
89
import com.gbsw.snapy.domain.auth.entity.RefreshToken;
910
import com.gbsw.snapy.domain.auth.repository.RefreshTokenRepository;
1011
import com.gbsw.snapy.domain.settings.entity.UserSetting;
@@ -56,11 +57,16 @@ public RegisterResponse register(RegisterRequest dto) {
5657
return RegisterResponse.from(user);
5758
}
5859

60+
// NOTE: 일반회원 로그인
5961
@Transactional
6062
public LoginServiceResult login(LoginRequest dto) {
6163
User user = userRepository.findByEmail(dto.getEmail())
6264
.orElseThrow(() -> new CustomException(ErrorCode.INVALID_CREDENTIALS));
6365

66+
if (user.getProvider() != OAuthProvider.LOCAL) {
67+
throw new CustomException(ErrorCode.INVALID_CREDENTIALS);
68+
}
69+
6470
if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
6571
throw new CustomException(ErrorCode.INVALID_CREDENTIALS);
6672
}
@@ -124,4 +130,4 @@ private String hash(String token) {
124130
throw new IllegalStateException("SHA-256 not available", e);
125131
}
126132
}
127-
}
133+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.gbsw.snapy.domain.auth.service;
2+
3+
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
4+
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
5+
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
6+
import com.google.api.client.http.HttpTransport;
7+
import com.google.api.client.json.gson.GsonFactory;
8+
import com.gbsw.snapy.domain.auth.dto.internal.GoogleUserInfo;
9+
import com.gbsw.snapy.global.exception.CustomException;
10+
import com.gbsw.snapy.global.exception.ErrorCode;
11+
import org.springframework.stereotype.Component;
12+
13+
import java.io.IOException;
14+
import java.security.GeneralSecurityException;
15+
import java.util.Collections;
16+
import java.util.Map;
17+
import java.util.concurrent.ConcurrentHashMap;
18+
19+
@Component
20+
public class GoogleIdTokenValidator {
21+
22+
private final HttpTransport transport;
23+
private final Map<String, GoogleIdTokenVerifier> verifiers = new ConcurrentHashMap<>();
24+
25+
public GoogleIdTokenValidator() {
26+
try {
27+
this.transport = GoogleNetHttpTransport.newTrustedTransport();
28+
} catch (GeneralSecurityException | IOException e) {
29+
throw new IllegalStateException("Failed to initialize Google ID token verifier", e);
30+
}
31+
}
32+
33+
public GoogleUserInfo verify(String idToken, String expectedAudience) {
34+
if (idToken == null || idToken.isBlank()
35+
|| expectedAudience == null || expectedAudience.isBlank()) {
36+
throw new CustomException(ErrorCode.GOOGLE_LOGIN_FAILED);
37+
}
38+
39+
try {
40+
GoogleIdToken verifiedToken = getVerifier(expectedAudience).verify(idToken);
41+
if (verifiedToken == null) {
42+
throw new CustomException(ErrorCode.GOOGLE_LOGIN_FAILED);
43+
}
44+
45+
GoogleIdToken.Payload payload = verifiedToken.getPayload();
46+
if (payload.getSubject() == null || payload.getSubject().isBlank()
47+
|| payload.getEmail() == null || payload.getEmail().isBlank()
48+
|| !Boolean.TRUE.equals(payload.getEmailVerified())) {
49+
throw new CustomException(ErrorCode.GOOGLE_LOGIN_FAILED);
50+
}
51+
52+
return new GoogleUserInfo(
53+
payload.getSubject(),
54+
payload.getEmail(),
55+
(String) payload.get("name"),
56+
(String) payload.get("picture"),
57+
expectedAudience,
58+
true
59+
);
60+
} catch (CustomException e) {
61+
throw e;
62+
} catch (GeneralSecurityException | IOException | RuntimeException e) {
63+
throw new CustomException(ErrorCode.GOOGLE_LOGIN_FAILED);
64+
}
65+
}
66+
67+
private GoogleIdTokenVerifier getVerifier(String audience) {
68+
return verifiers.computeIfAbsent(audience, value ->
69+
new GoogleIdTokenVerifier.Builder(transport, GsonFactory.getDefaultInstance())
70+
.setAudience(Collections.singletonList(value))
71+
.build()
72+
);
73+
}
74+
}

src/main/java/com/gbsw/snapy/domain/auth/service/GoogleOAuthService.java

Lines changed: 25 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,14 @@
3535
public class GoogleOAuthService {
3636

3737
private static final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
38-
private static final String GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo";
39-
private static final String GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo";
4038

4139
private final UserRepository userRepository;
4240
private final UserSettingRepository userSettingRepository;
4341
private final RefreshTokenRepository refreshTokenRepository;
4442
private final JwtProvider jwtProvider;
4543
private final JwtProperties jwtProperties;
4644
private final GoogleOAuthProperties googleOAuthProperties;
45+
private final GoogleIdTokenValidator googleIdTokenValidator;
4746

4847
private final RestClient restClient = RestClient.create();
4948

@@ -52,7 +51,8 @@ public class GoogleOAuthService {
5251
@Transactional
5352
public LoginServiceResult processWebLogin(String code) {
5453
GoogleTokenResponse tokenResponse = exchangeCodeForToken(code);
55-
GoogleUserInfo userInfo = getUserInfo(tokenResponse.getAccessToken());
54+
GoogleUserInfo userInfo = googleIdTokenValidator.verify(
55+
tokenResponse.getIdToken(), googleOAuthProperties.getWeb().getClientId());
5656
return processOAuthLogin(userInfo);
5757
}
5858

@@ -64,7 +64,21 @@ public LoginServiceResult processWebLogin(String code) {
6464
*/
6565
@Transactional
6666
public LoginServiceResult processIosLogin(String idToken) {
67-
GoogleUserInfo userInfo = verifyIdToken(idToken);
67+
GoogleUserInfo userInfo = googleIdTokenValidator.verify(
68+
idToken, googleOAuthProperties.getIos().getClientId());
69+
return processOAuthLogin(userInfo);
70+
}
71+
72+
// ── Android ──────────────────────────────────────────────────────────────────
73+
74+
/**
75+
* Android: Credential Manager에서 받은 ID token으로 로그인.
76+
* Android의 serverClientId는 서버의 Web OAuth client ID와 같아야 한다.
77+
*/
78+
@Transactional
79+
public LoginServiceResult processAndroidLogin(String idToken) {
80+
GoogleUserInfo userInfo = googleIdTokenValidator.verify(
81+
idToken, googleOAuthProperties.getWeb().getClientId());
6882
return processOAuthLogin(userInfo);
6983
}
7084

@@ -92,14 +106,18 @@ private LoginServiceResult processOAuthLogin(GoogleUserInfo userInfo) {
92106
// ── 회원가입 (AuthService.register 와 동일한 역할) ─────────────────────────
93107

94108
private User registerOAuthUser(GoogleUserInfo userInfo) {
95-
// 같은 이메일로 일반 가입한 계정이 이미 있으면 오류
96109
userRepository.findByEmail(userInfo.getEmail()).ifPresent(u -> {
97-
throw new CustomException(ErrorCode.GOOGLE_LOGIN_FAILED);
110+
throw new CustomException(ErrorCode.EMAIL_REGISTERED_WITH_DIFFERENT_PROVIDER);
98111
});
99112

113+
String username = userInfo.getName();
114+
if (username == null || username.isBlank()) {
115+
username = "Snapy User";
116+
}
117+
100118
User user = User.builder()
101119
.handle(generateUniqueHandle(userInfo.getSub()))
102-
.username(userInfo.getName())
120+
.username(username)
103121
.email(userInfo.getEmail())
104122
.provider(OAuthProvider.GOOGLE)
105123
.providerId(userInfo.getSub())
@@ -149,43 +167,6 @@ private GoogleTokenResponse exchangeCodeForToken(String code) {
149167
}
150168
}
151169

152-
private GoogleUserInfo getUserInfo(String accessToken) {
153-
try {
154-
return restClient.get()
155-
.uri(GOOGLE_USERINFO_URL)
156-
.header("Authorization", "Bearer " + accessToken)
157-
.retrieve()
158-
.body(GoogleUserInfo.class);
159-
} catch (Exception e) {
160-
throw new CustomException(ErrorCode.GOOGLE_LOGIN_FAILED);
161-
}
162-
}
163-
164-
private GoogleUserInfo verifyIdToken(String idToken) {
165-
try {
166-
GoogleUserInfo userInfo = restClient.get()
167-
.uri(GOOGLE_TOKENINFO_URL + "?id_token=" + idToken)
168-
.retrieve()
169-
.body(GoogleUserInfo.class);
170-
171-
if (userInfo == null || !userInfo.isEmailVerified()) {
172-
throw new CustomException(ErrorCode.GOOGLE_LOGIN_FAILED);
173-
}
174-
175-
String iosClientId = googleOAuthProperties.getIos().getClientId();
176-
if (iosClientId != null && !iosClientId.isBlank()
177-
&& !iosClientId.equals(userInfo.getAud())) {
178-
throw new CustomException(ErrorCode.GOOGLE_LOGIN_FAILED);
179-
}
180-
181-
return userInfo;
182-
} catch (CustomException e) {
183-
throw e;
184-
} catch (Exception e) {
185-
throw new CustomException(ErrorCode.GOOGLE_LOGIN_FAILED);
186-
}
187-
}
188-
189170
// ── 유틸 ──────────────────────────────────────────────────────────────────
190171

191172
// TODO: 동시성 문제 발생시 트랜잭션 적용 필요

src/main/java/com/gbsw/snapy/global/exception/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public enum ErrorCode {
2828
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."),
2929
DELETED_USER(HttpStatus.NOT_FOUND, "탈퇴한 사용자입니다."),
3030
INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."),
31+
EMAIL_REGISTERED_WITH_DIFFERENT_PROVIDER(HttpStatus.CONFLICT, "해당 이메일은 이미 다른 방식으로 가입되어 있습니다."),
3132
DUPLICATE_HANDLE(HttpStatus.CONFLICT, "이미 사용 중인 핸들입니다."),
3233
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다."),
3334
DUPLICATE_PHONE(HttpStatus.CONFLICT, "이미 사용 중인 전화번호입니다."),
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.gbsw.snapy.domain.auth.controller;
2+
3+
import com.gbsw.snapy.domain.auth.dto.response.LoginServiceResult;
4+
import com.gbsw.snapy.domain.auth.service.AppleOAuthService;
5+
import com.gbsw.snapy.domain.auth.service.GoogleOAuthService;
6+
import com.gbsw.snapy.global.exception.GlobalExceptionHandler;
7+
import com.gbsw.snapy.global.oauth.AppleOAuthProperties;
8+
import com.gbsw.snapy.global.oauth.GoogleOAuthProperties;
9+
import com.gbsw.snapy.global.security.jwt.JwtProperties;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.api.extension.ExtendWith;
13+
import org.mockito.InjectMocks;
14+
import org.mockito.Mock;
15+
import org.mockito.junit.jupiter.MockitoExtension;
16+
import org.springframework.http.MediaType;
17+
import org.springframework.test.web.servlet.MockMvc;
18+
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
19+
20+
import static org.mockito.Mockito.verify;
21+
import static org.mockito.Mockito.when;
22+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
23+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
24+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
25+
26+
@ExtendWith(MockitoExtension.class)
27+
class OAuthControllerTest {
28+
29+
@Mock private GoogleOAuthService googleOAuthService;
30+
@Mock private GoogleOAuthProperties googleOAuthProperties;
31+
@Mock private AppleOAuthService appleOAuthService;
32+
@Mock private AppleOAuthProperties appleOAuthProperties;
33+
@Mock private JwtProperties jwtProperties;
34+
35+
@InjectMocks
36+
private OAuthController oauthController;
37+
38+
private MockMvc mockMvc;
39+
40+
@BeforeEach
41+
void setUp() {
42+
mockMvc = MockMvcBuilders.standaloneSetup(oauthController)
43+
.setControllerAdvice(new GlobalExceptionHandler())
44+
.build();
45+
}
46+
47+
@Test
48+
void androidLoginReturnsServerTokens() throws Exception {
49+
when(googleOAuthService.processAndroidLogin("google-id-token"))
50+
.thenReturn(new LoginServiceResult("access-token", "refresh-token"));
51+
52+
mockMvc.perform(post("/api/auth/google/android")
53+
.contentType(MediaType.APPLICATION_JSON)
54+
.content("{\"idToken\":\"google-id-token\"}"))
55+
.andExpect(status().isOk())
56+
.andExpect(jsonPath("$.success").value(true))
57+
.andExpect(jsonPath("$.data.accessToken").value("access-token"))
58+
.andExpect(jsonPath("$.data.refreshToken").value("refresh-token"));
59+
60+
verify(googleOAuthService).processAndroidLogin("google-id-token");
61+
}
62+
63+
@Test
64+
void androidLoginRejectsBlankIdToken() throws Exception {
65+
mockMvc.perform(post("/api/auth/google/android")
66+
.contentType(MediaType.APPLICATION_JSON)
67+
.content("{\"idToken\":\"\"}"))
68+
.andExpect(status().isBadRequest());
69+
}
70+
71+
}

0 commit comments

Comments
 (0)