Skip to content
Merged

Dev #310

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
4 changes: 2 additions & 2 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ jobs:
JWT_ACCESS_HEADER=${{ secrets.DEV_JWT_ACCESS_HEADER || 'Authorization' }}
JWT_REFRESH_HEADER=${{ secrets.DEV_JWT_REFRESH_HEADER || 'Authorization-refresh' }}
GOOGLE_WEB_CLIENT_ID=${{ secrets.DEV_GOOGLE_WEB_CLIENT_ID || 'dev-google-web-client-id' }}
GOOGLE_APP_CLIENT_ID=${{ secrets.DEV_GOOGLE_APP_CLIENT_ID || 'dev-google-app-client-id' }}
GOOGLE_WEB_CLIENT_ID=${{ secrets.GOOGLE_WEB_CLIENT_ID || 'dev-google-web-client-id' }}
GOOGLE_APP_CLIENT_ID=${{ secrets.GOOGLE_APP_CLIENT_ID || 'dev-google-app-client-id' }}
APPLE_CLIENT_ID=${{ secrets.DEV_APPLE_CLIENT_ID || 'dev-apple-client-id' }}
APPLE_TEAM_ID=${{ secrets.DEV_APPLE_TEAM_ID || 'dev-apple-team-id' }}
Expand Down
8 changes: 4 additions & 4 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ Optional development secrets:
- `DEV_JWT_REFRESH_EXPIRATION`
- `DEV_JWT_ACCESS_HEADER`
- `DEV_JWT_REFRESH_HEADER`
- `DEV_GOOGLE_WEB_CLIENT_ID`
- `DEV_GOOGLE_APP_CLIENT_ID`
- `GOOGLE_WEB_CLIENT_ID`
- `GOOGLE_APP_CLIENT_ID`
- `DEV_APPLE_CLIENT_ID`
- `DEV_APPLE_TEAM_ID`
- `DEV_APPLE_LOGIN_KEY`
Expand Down Expand Up @@ -212,8 +212,8 @@ Optional development secrets:
- `DEV_JWT_REFRESH_EXPIRATION`
- `DEV_JWT_ACCESS_HEADER`
- `DEV_JWT_REFRESH_HEADER`
- `DEV_GOOGLE_WEB_CLIENT_ID`
- `DEV_GOOGLE_APP_CLIENT_ID`
- `GOOGLE_WEB_CLIENT_ID`
- `GOOGLE_APP_CLIENT_ID`
- `DEV_APPLE_CLIENT_ID`
- `DEV_APPLE_TEAM_ID`
- `DEV_APPLE_LOGIN_KEY`
Expand Down
2 changes: 1 addition & 1 deletion ontime-back/docs/deployment/ec2.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The workflow builds a Docker image, pushes it to GHCR, uploads `docker-compose.y
- `JWT_ACCESS_HEADER`
- `JWT_REFRESH_HEADER`
- `GOOGLE_WEB_CLIENT_ID`
- `GOOGLE_APP_CLIENT_ID`
- `GOOGLE_APP_CLIENT_ID` (comma-separated iOS and Android client IDs when both platforms are enabled)
- `APPLE_CLIENT_ID`
- `APPLE_LOGIN_KEY`
- `APPLE_TEAM_ID`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import jakarta.validation.Validator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
Expand Down Expand Up @@ -57,7 +58,14 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ

try {
GoogleIdToken.Payload googlePayload = googleLoginService.verifyIdentityToken(oAuthGoogleRequestDto.getIdToken());
if (googlePayload == null) {
throw new BadCredentialsException("Invalid Google identity token");
}

String googleUserId = googlePayload.getSubject();
if (googleUserId == null || googleUserId.isBlank()) {
throw new BadCredentialsException("Google identity token has no subject");
}

Object loginLock = LOGIN_LOCKS.computeIfAbsent(googleUserId, key -> new Object());
synchronized (loginLock) {
Expand All @@ -84,6 +92,9 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ
}
}

} catch (AuthenticationException e) {
log.warn("Google login failed: {}", e.getMessage());
throw e;
} catch (Exception e) {
log.error("Google login failed: {}", e.getClass().getSimpleName());
throw new AuthenticationException("Google 로그인 실패") {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;

@Slf4j
@Service
Expand All @@ -58,7 +59,16 @@ public GoogleLoginService(
this.jwtTokenProvider = jwtTokenProvider;
this.userRepository = userRepository;
this.userAlarmSettingRepository = userAlarmSettingRepository;
this.validClientIds = List.of(webClientId, appClientId);
this.validClientIds = Stream.concat(
Stream.of(webClientId),
Stream.of(appClientId.split(","))
)
.map(String::trim)
.filter(clientId -> !clientId.isBlank())
.toList();
log.info("Configured Google OAuth audiences: {}", validClientIds.stream()
.map(this::maskClientId)
.toList());
}


Expand Down Expand Up @@ -166,10 +176,52 @@ public GoogleIdToken.Payload verifyIdentityToken(String identityToken) throws Ex
return payload;
} else {
log.info("Google identity credential is invalid");
logGoogleIdentityTokenClaims(identityToken);
return null;
}
}

private void logGoogleIdentityTokenClaims(String identityToken) {
try {
GoogleIdToken parsedToken = GoogleIdToken.parse(
GsonFactory.getDefaultInstance(),
identityToken
);
GoogleIdToken.Payload payload = parsedToken.getPayload();
String audience = String.valueOf(payload.getAudience());
Long expirationTimeSeconds = payload.getExpirationTimeSeconds();
long nowSeconds = System.currentTimeMillis() / 1000;
log.info(
"Google identity token claims aud={}, azp={}, iss={}, exp={}, now={}, secondsUntilExp={}, audienceAllowed={}",
maskClientId(audience),
payload.get("azp"),
payload.getIssuer(),
expirationTimeSeconds,
nowSeconds,
expirationTimeSeconds == null ? null : expirationTimeSeconds - nowSeconds,
validClientIds.contains(audience)
);
} catch (Exception e) {
log.info("Google identity token claim parsing failed: {}", e.getClass().getSimpleName());
}
}

private String maskClientId(String clientId) {
int separatorIndex = clientId.indexOf('-');
if (separatorIndex < 0) {
return "<invalid-client-id>";
}
String projectNumber = clientId.substring(0, separatorIndex);
String suffix = ".apps.googleusercontent.com";
boolean hasGoogleSuffix = clientId.endsWith(suffix);
String middle = clientId.substring(
separatorIndex + 1,
hasGoogleSuffix ? clientId.length() - suffix.length() : clientId.length()
);
String visibleTail = middle.length() <= 4 ? middle : middle.substring(middle.length() - 4);
return projectNumber + "-..." + visibleTail + (hasGoogleSuffix ? suffix : "");
}

public boolean revokeToken(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found with id: " + userId));
Expand Down
4 changes: 2 additions & 2 deletions ontime-back/src/main/resources/application-dev.properties
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ jwt.access.header=${JWT_ACCESS_HEADER:Authorization}
jwt.refresh.header=${JWT_REFRESH_HEADER:Authorization-refresh}

# Google OAuth
google.web.client-id=${GOOGLE_WEB_CLIENT_ID:dev-google-web-client-id}
google.app.client-id=${GOOGLE_APP_CLIENT_ID:dev-google-app-client-id}
google.web.client-id=${GOOGLE_WEB_CLIENT_ID:456571312261-5kuf2r6i5i7lqjr7qealv06sdgkn3hcp.apps.googleusercontent.com}
google.app.client-id=${GOOGLE_APP_CLIENT_ID:456571312261-r35ah9qi0qaq7al007e2db0e0jmjcmb4.apps.googleusercontent.com,456571312261-5e99nruk62f21uoh7stfp8i82acmh6iq.apps.googleusercontent.com,456571312261-6470v6goejjkcqn3608b4nbbtpt6dknu.apps.googleusercontent.com}

# Apple OAuth
apple.client.id=${APPLE_CLIENT_ID:dev-apple-client-id}
Expand Down
4 changes: 4 additions & 0 deletions ontime-back/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ spring.flyway.baseline-on-migrate=false
logging.level.root=INFO
logging.level.devkor.ontime_back=INFO

# Google OAuth
google.web.client-id=${GOOGLE_WEB_CLIENT_ID}
google.app.client-id=${GOOGLE_APP_CLIENT_ID}

# Actuator
management.endpoint.health.probes.enabled=true
management.endpoints.web.exposure.include=health
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class OAuthLoginFilterValidationTest {
Expand Down Expand Up @@ -65,6 +68,29 @@ void googleLoginFilterRejectsInvalidRequest() throws Exception {
verifyNoInteractions(googleLoginService, userRepository);
}

@Test
@DisplayName("구글 로그인 필터가 검증 실패한 idToken을 인증 실패로 처리한다")
void googleLoginFilterRejectsUnverifiedIdToken() throws Exception {
GoogleLoginFilter filter = new GoogleLoginFilter(
"/oauth2/google/login",
objectMapper,
validator,
googleLoginService,
userRepository);
MockHttpServletResponse response = new MockHttpServletResponse();

when(googleLoginService.verifyIdentityToken("invalid-token")).thenReturn(null);

assertThatThrownBy(() -> filter.attemptAuthentication(
request("/oauth2/google/login", "{\"idToken\":\"invalid-token\"}"),
response))
.isInstanceOf(BadCredentialsException.class)
.hasMessage("Invalid Google identity token");

verify(googleLoginService).verifyIdentityToken("invalid-token");
verifyNoInteractions(userRepository);
}

@Test
@DisplayName("카카오 로그인 필터가 잘못된 요청을 400 validation 응답으로 처리한다")
void kakaoLoginFilterRejectsInvalidRequest() throws Exception {
Expand Down
Loading