diff --git a/build.gradle b/build.gradle index 9b804d8615..1586eb19f7 100644 --- a/build.gradle +++ b/build.gradle @@ -241,7 +241,7 @@ dependencies { implementation("org.apache.commons:commons-compress:1.27.1") implementation("net.java.dev.jna:jna:5.14.0") implementation("net.java.dev.jna:jna-platform:5.14.0") - implementation("org.jetbrains:annotations:24.1.0") + implementation("org.jetbrains:annotations:26.0.1") implementation("com.neovisionaries:nv-i18n:1.29") implementation("com.nativelibs4java:bridj:0.7.0") implementation("org.luaj:luaj-jse:3.0.1") diff --git a/gradle.properties b/gradle.properties index d448f1ffc4..b8f3563174 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ version=unspecified javafxPlatform=unspecified -iceAdapterVersion=3.3.9 +iceAdapterVersion=3.3.10 diff --git a/src/main/java/com/faforever/client/api/TokenRetriever.java b/src/main/java/com/faforever/client/api/TokenRetriever.java index 1199a521d1..3987147d9d 100644 --- a/src/main/java/com/faforever/client/api/TokenRetriever.java +++ b/src/main/java/com/faforever/client/api/TokenRetriever.java @@ -43,8 +43,7 @@ public class TokenRetriever implements InitializingBean { private final Mono refreshedTokenMono = Mono.defer(this::refreshAccess) .cacheInvalidateWhen(this::getExpirationMono) - - .map(OAuth2AccessToken::getTokenValue); + .map(OAuth2AccessToken::getTokenValue); @Override public void afterPropertiesSet() throws Exception { @@ -130,4 +129,8 @@ public void invalidateToken() { public Flux invalidationFlux() { return invalidateFlux; } + + public Mono getAccessToken() { + return refreshedTokenMono; + } } diff --git a/src/main/java/com/faforever/client/game/GameRunner.java b/src/main/java/com/faforever/client/game/GameRunner.java index 786406d336..55199ea221 100644 --- a/src/main/java/com/faforever/client/game/GameRunner.java +++ b/src/main/java/com/faforever/client/game/GameRunner.java @@ -373,6 +373,7 @@ private boolean waitingForMatchMakerGame() { private Process launchOnlineGame(GameParameters gameParameters, Integer gpgPort, Integer replayPort) { fafServerAccessor.setPingIntervalSeconds(5); + fafServerAccessor.setTimeoutLoginReconnectSeconds(5); gameKilled = false; return forgedAllianceLaunchService.launchOnlineGame(gameParameters, gpgPort, replayPort); } @@ -400,6 +401,7 @@ private Mono getDivisionInfo(String leaderboard) { private void handleTermination(Process finishedProcess) { fafServerAccessor.setPingIntervalSeconds(25); + fafServerAccessor.setTimeoutLoginReconnectSeconds(30); int exitCode = finishedProcess.exitValue(); log.info("Forged Alliance terminated with exit code {}", exitCode); Optional logFile = loggingService.getMostRecentGameLogFile(); diff --git a/src/main/java/com/faforever/client/headerbar/UserButtonController.java b/src/main/java/com/faforever/client/headerbar/UserButtonController.java index 050504e59f..bcc4642dd7 100644 --- a/src/main/java/com/faforever/client/headerbar/UserButtonController.java +++ b/src/main/java/com/faforever/client/headerbar/UserButtonController.java @@ -1,28 +1,35 @@ package com.faforever.client.headerbar; +import com.faforever.client.api.TokenRetriever; import com.faforever.client.domain.server.PlayerInfo; +import com.faforever.client.fx.FxApplicationThreadExecutor; import com.faforever.client.fx.NodeController; import com.faforever.client.player.PlayerInfoWindowController; import com.faforever.client.player.PlayerService; import com.faforever.client.reporting.ReportDialogController; import com.faforever.client.theme.UiService; import com.faforever.client.user.LoginService; +import com.faforever.client.util.ClipboardUtil; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.MenuButton; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; @Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j @RequiredArgsConstructor public class UserButtonController extends NodeController { + private final FxApplicationThreadExecutor fxApplicationThreadExecutor; private final PlayerService playerService; private final UiService uiService; private final LoginService loginService; + private final TokenRetriever tokenRetriever; public MenuButton userMenuButtonRoot; @@ -57,6 +64,13 @@ public void onReport() { reportDialogController.show(); } + public void onCopyAccessToken() { + tokenRetriever.getAccessToken().publishOn(fxApplicationThreadExecutor.asScheduler()).subscribe(accessToken -> { + log.info("Copied access token to clipboard"); + ClipboardUtil.copyToClipboard(accessToken); + }); + } + public void onLogOut() { loginService.logOut(); } diff --git a/src/main/java/com/faforever/client/login/KnownLoginErrorException.java b/src/main/java/com/faforever/client/login/KnownLoginErrorException.java new file mode 100644 index 0000000000..b27944b678 --- /dev/null +++ b/src/main/java/com/faforever/client/login/KnownLoginErrorException.java @@ -0,0 +1,13 @@ +package com.faforever.client.login; + +import lombok.Getter; + +@Getter +public class KnownLoginErrorException extends RuntimeException { + private final String i18nKey; + + public KnownLoginErrorException(String message, String i18nKey) { + super(message); + this.i18nKey = i18nKey; + } +} diff --git a/src/main/java/com/faforever/client/login/LoginController.java b/src/main/java/com/faforever/client/login/LoginController.java index 704ec509cc..6c916226e4 100644 --- a/src/main/java/com/faforever/client/login/LoginController.java +++ b/src/main/java/com/faforever/client/login/LoginController.java @@ -55,7 +55,6 @@ @Slf4j @RequiredArgsConstructor public class LoginController extends NodeController { - private final OperatingSystem operatingSystem; private final GameRunner gameRunner; private final LoginService loginService; @@ -292,6 +291,8 @@ private Void onLoginFailed(Throwable throwable) { notificationService.addNotification( new ServerNotification(i18n.get("login.failed"), loginException.getMessage(), Severity.ERROR, List.of(new DismissAction(i18n)))); + } else if (throwable instanceof KnownLoginErrorException loginException) { + notificationService.addImmediateErrorNotification(throwable, loginException.getI18nKey()); } else { log.error("Could not log in", throwable); notificationService.addImmediateErrorNotification(throwable, "login.failed"); diff --git a/src/main/java/com/faforever/client/login/OAuthValuesReceiver.java b/src/main/java/com/faforever/client/login/OAuthValuesReceiver.java index 97655c7b20..599bc5e7f0 100644 --- a/src/main/java/com/faforever/client/login/OAuthValuesReceiver.java +++ b/src/main/java/com/faforever/client/login/OAuthValuesReceiver.java @@ -19,6 +19,7 @@ import java.net.ServerSocket; import java.net.Socket; import java.net.URI; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -33,6 +34,9 @@ public class OAuthValuesReceiver { private static final Pattern CODE_PATTERN = Pattern.compile("code=([^ &]+)"); private static final Pattern STATE_PATTERN = Pattern.compile("state=([^ &]+)"); + private static final Pattern ERROR_PATTERN = Pattern.compile("error=([^ &]+)"); + private static final Pattern ERROR_SCOPE_DENIED = Pattern.compile("scope_denied"); + private static final Pattern ERROR_NO_CSRF = Pattern.compile("No\\+CSRF\\+value"); private final PlatformService platformService; private final LoginService loginService; @@ -89,6 +93,7 @@ private Values readValues(String state, String codeVerifier) { // Do not try with resources as the socket needs to stay open. try { + checkForError(request); Values values = readValues(request, redirectUri); success = true; return values; @@ -139,13 +144,32 @@ private Values readValues(String request, URI redirectUri) { return new Values(code, state, redirectUri); } + private String formatRequest(String request) { + return URLDecoder.decode(request, StandardCharsets.UTF_8); + } + private String extractValue(String request, Pattern pattern) { Matcher matcher = pattern.matcher(request); if (!matcher.find()) { - throw new IllegalStateException("Could not extract value with pattern '" + pattern + "' from: " + request); + throw new IllegalStateException("Could not extract value with pattern '" + pattern + "' from: " + formatRequest(request)); } return matcher.group(1); } + private void checkForError(String request) { + Matcher matcher = ERROR_PATTERN.matcher(request); + if (matcher.find()) { + String errorMessage = "Login failed with error '" + matcher.group(1) + "'. The full request is: " + formatRequest(request); + if (ERROR_SCOPE_DENIED.matcher(request).find()) { + throw new KnownLoginErrorException(errorMessage, "login.scopeDenied"); + } + + if (ERROR_NO_CSRF.matcher(request).find()) { + throw new KnownLoginErrorException(errorMessage, "login.noCSRF"); + } + throw new IllegalStateException(errorMessage); + } + } + public record Values(String code, String state, URI redirectUri) {} } diff --git a/src/main/java/com/faforever/client/remote/FafServerAccessor.java b/src/main/java/com/faforever/client/remote/FafServerAccessor.java index b01797bc9d..6161d450de 100644 --- a/src/main/java/com/faforever/client/remote/FafServerAccessor.java +++ b/src/main/java/com/faforever/client/remote/FafServerAccessor.java @@ -35,6 +35,7 @@ import javafx.beans.property.ReadOnlyObjectWrapper; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; @@ -83,6 +84,9 @@ public class FafServerAccessor implements InitializingBean, DisposableBean, Life private boolean autoReconnect; @Getter private boolean running; + @Getter + @Setter + private int timeoutLoginReconnectSeconds; @Override public void afterPropertiesSet() throws Exception { @@ -98,6 +102,7 @@ public void start() { .subscribe(); setPingIntervalSeconds(25); + setTimeoutLoginReconnectSeconds(30); lobbyClient.getConnectionStatus() .map(connectionStatus -> switch (connectionStatus) { @@ -161,7 +166,7 @@ public Mono connectAndLogIn() { clientProperties.getUserAgent(), lobbyUrl, this::tryGenerateUid, 1024 * 1024, false))) .flatMap(lobbyClient::connectAndLogin) - .timeout(Duration.ofSeconds(30)) + .timeout(Duration.ofSeconds(timeoutLoginReconnectSeconds)) .retryWhen(createRetrySpec(clientProperties.getServer())); } diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index c3d1e31c07..55f1bc6227 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -21,6 +21,7 @@ view.tiles = Tiles view.table = Table userMenu.logOut = Log out userMenu.showProfile = Show profile +userMenu.copyAccessToken=Copy access token menu.feedback = Feedback menu.exit = Exit menu.settings = Settings @@ -1088,6 +1089,8 @@ map.all = All Versions map.current = Current Version login.remember = Remember Me login.failed = Error occurred during login +login.scopeDenied = Login failed. You did not accept the scopes on the login page. +login.noCSRF = Login failed. Likely your login timed out, please try again. login.badState = State returned by user service does not match initial state if this continues to occur please reach out to technical help on the forum or discord channel login.oauthBaseUrl = OAuth base URL session.expired.title = Session Expired diff --git a/src/main/resources/theme/headerbar/user_button.fxml b/src/main/resources/theme/headerbar/user_button.fxml index bc6df83e25..9c4ddfb319 100644 --- a/src/main/resources/theme/headerbar/user_button.fxml +++ b/src/main/resources/theme/headerbar/user_button.fxml @@ -9,6 +9,7 @@ + diff --git a/src/test/java/com/faforever/client/api/TokenRetrieverTest.java b/src/test/java/com/faforever/client/api/TokenRetrieverTest.java index 8d6b3ec666..7768e47f25 100644 --- a/src/test/java/com/faforever/client/api/TokenRetrieverTest.java +++ b/src/test/java/com/faforever/client/api/TokenRetrieverTest.java @@ -183,4 +183,16 @@ public void testGetRefreshToken() throws Exception { assertEquals(tokenProperties.get(REFRESH_TOKEN), loginPrefs.getRefreshToken()); } + + @Test + public void testGetAccessToken() throws Exception { + loginPrefs.setRememberMe(true); + Map tokenProperties = Map.of(EXPIRES_IN, "3600", REFRESH_TOKEN, "refresh", ACCESS_TOKEN, "test", + TOKEN_TYPE, "bearer"); + prepareTokenResponse(tokenProperties); + + StepVerifier.create(instance.getAccessToken()) + .assertNext(accessToken -> assertEquals(accessToken, "test")) + .verifyComplete(); + } } diff --git a/src/test/java/com/faforever/client/game/GameRunnerTest.java b/src/test/java/com/faforever/client/game/GameRunnerTest.java index 076a2a0bb4..0170ceb177 100644 --- a/src/test/java/com/faforever/client/game/GameRunnerTest.java +++ b/src/test/java/com/faforever/client/game/GameRunnerTest.java @@ -215,6 +215,7 @@ public void testStartOnlineGlobalGame() throws Exception { Integer uid = gameParameters.uid(); verify(fafServerAccessor).setPingIntervalSeconds(5); + verify(fafServerAccessor).setTimeoutLoginReconnectSeconds(5); verify(leaderboardService, never()).getActiveLeagueEntryForPlayer(any(), any()); verify(mapService, never()).downloadIfNecessary(any()); verify(replayServer).start(uid); @@ -234,6 +235,7 @@ public void testStartOnlineGlobalGame() throws Exception { verify(replayServer).stop(); verify(fafServerAccessor).notifyGameEnded(); verify(fafServerAccessor).setPingIntervalSeconds(25); + verify(fafServerAccessor).setTimeoutLoginReconnectSeconds(30); verify(notificationService).addNotification(any(PersistentNotification.class)); assertFalse(instance.isRunning()); assertNull(instance.getRunningProcessId()); diff --git a/src/test/java/com/faforever/client/headerbar/UserButtonControllerTest.java b/src/test/java/com/faforever/client/headerbar/UserButtonControllerTest.java index d459bc0338..0c57098d5c 100644 --- a/src/test/java/com/faforever/client/headerbar/UserButtonControllerTest.java +++ b/src/test/java/com/faforever/client/headerbar/UserButtonControllerTest.java @@ -1,5 +1,6 @@ package com.faforever.client.headerbar; +import com.faforever.client.api.TokenRetriever; import com.faforever.client.builders.PlayerInfoBuilder; import com.faforever.client.domain.server.PlayerInfo; import com.faforever.client.player.PlayerInfoWindowController; @@ -13,9 +14,11 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import reactor.core.publisher.Mono; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class UserButtonControllerTest extends PlatformTest { private static final String TEST_USER_NAME = "junit"; @@ -30,6 +33,8 @@ public class UserButtonControllerTest extends PlatformTest { private ReportDialogController reportDialogController; @Mock private PlayerInfoWindowController playerInfoWindowController; + @Mock + private TokenRetriever tokenRetriever; @InjectMocks @@ -64,6 +69,15 @@ public void testReport() { verify(reportDialogController).show(); } + @Test + public void testCopyAccessToken() { + when(tokenRetriever.getAccessToken()).thenReturn(Mono.just("someToken")); + + instance.onCopyAccessToken(); + + verify(tokenRetriever).getAccessToken(); + } + @Test public void testLogOut() { instance.onLogOut(); diff --git a/src/test/java/com/faforever/client/login/LoginControllerTest.java b/src/test/java/com/faforever/client/login/LoginControllerTest.java index 029cb6d7f0..ba18fabf19 100644 --- a/src/test/java/com/faforever/client/login/LoginControllerTest.java +++ b/src/test/java/com/faforever/client/login/LoginControllerTest.java @@ -160,7 +160,7 @@ public void testLoginFails() throws Exception { @Test public void testLoginFailsNoPorts() throws Exception { when(oAuthValuesReceiver.receiveValues(anyString(), anyString())) - .thenReturn(CompletableFuture.failedFuture(new IllegalStateException())); + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException(""))); instance.onLoginButtonClicked(); WaitForAsyncUtils.waitForFxEvents(); @@ -170,6 +170,19 @@ public void testLoginFailsNoPorts() throws Exception { assertTrue(instance.loginFormPane.isVisible()); } + @Test + public void testLoginFailsKnownError() throws Exception { + when(oAuthValuesReceiver.receiveValues(anyString(), anyString())) + .thenReturn(CompletableFuture.failedFuture(new KnownLoginErrorException("", "login.known"))); + + instance.onLoginButtonClicked(); + WaitForAsyncUtils.waitForFxEvents(); + + verify(notificationService).addImmediateErrorNotification(any(), eq("login.known")); + assertFalse(instance.loginProgressPane.isVisible()); + assertTrue(instance.loginFormPane.isVisible()); + } + @Test public void testLoginFailsTimeout() throws Exception { when(oAuthValuesReceiver.receiveValues(anyString(), anyString())) @@ -236,7 +249,7 @@ public void testLoginRefreshFails() { .thenReturn(CompletableFuture.completedFuture(ClientConfigurationBuilder.create().defaultValues().get())); loginPrefs.setRememberMe(true); loginPrefs.setRefreshToken("abc"); - when(loginService.loginWithRefreshToken()).thenReturn(Mono.error(new Exception())); + when(loginService.loginWithRefreshToken()).thenReturn(Mono.error(new Exception(""))); runOnFxThreadAndWait(() -> reinitialize(instance)); verify(loginService).loginWithRefreshToken(); assertFalse(instance.loginProgressPane.isVisible());