diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 67ff11e010..7fca890329 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -612,6 +612,16 @@ Days = Server limit reached! Please wait for [time] seconds = File could not be found on the multiplayer server = Unhandled problem, [errorMessage] = +Please enter your server password = +Set password = +Failed to set password! = +Password set successfully for server [serverURL] = +Password = +Your userId is password secured = +Set a password to secure your userId = +Authenticate = +This server does not support authentication = +Authentication failed = # Save game menu diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 24d157e675..96d4a40d8a 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -197,6 +197,13 @@ class UncivGame(parameters: UncivGameParameters) : Game() { onlineMultiplayer = OnlineMultiplayer() + // Check if the server is available in case the feature set has changed + try { + onlineMultiplayer.checkServerStatus() + } catch (ex: Exception) { + debug("Couldn't connect to server: " + ex.message) + } + ImageGetter.resetAtlases() ImageGetter.setNewRuleset(ImageGetter.ruleset) // This needs to come after the settings, since we may have default visual mods if (settings.tileSet !in ImageGetter.getAvailableTilesets()) { // If one of the tilesets is no longer available, default back diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index cece2e67cc..ce731b1eae 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -3,15 +3,19 @@ package com.unciv.logic.multiplayer import com.badlogic.gdx.files.FileHandle import com.unciv.Constants import com.unciv.UncivGame +import com.unciv.json.json import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview import com.unciv.logic.civilization.NotificationCategory import com.unciv.logic.civilization.PlayerType import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException import com.unciv.logic.multiplayer.storage.OnlineMultiplayerFiles import com.unciv.ui.components.extensions.isLargerThan +import com.unciv.logic.multiplayer.storage.SimpleHttp +import com.unciv.utils.Log import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Dispatcher import com.unciv.utils.concurrency.launchOnThreadPool @@ -42,6 +46,7 @@ private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) class OnlineMultiplayer { private val files = UncivGame.Current.files private val multiplayerFiles = OnlineMultiplayerFiles() + private var featureSet = ServerFeatureSet() private val savedGames: MutableMap = Collections.synchronizedMap(mutableMapOf()) @@ -50,6 +55,7 @@ class OnlineMultiplayer { private val lastCurGameRefresh: AtomicReference = AtomicReference() val games: Set get() = savedGames.values.toSet() + val serverFeatureSet: ServerFeatureSet get() = featureSet init { flow { @@ -178,6 +184,7 @@ class OnlineMultiplayer { * * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws MultiplayerFileNotFoundException if the file can't be found + * @throws MultiplayerAuthException if the authentication failed * @return false if it's not the user's turn and thus resigning did not happen */ suspend fun resign(game: OnlineMultiplayerGame): Boolean { @@ -301,6 +308,7 @@ class OnlineMultiplayer { /** * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws MultiplayerFileNotFoundException if the file can't be found + * @throws MultiplayerAuthException if the authentication failed */ suspend fun updateGame(gameInfo: GameInfo) { debug("Updating remote game %s", gameInfo.gameId) @@ -323,6 +331,67 @@ class OnlineMultiplayer { && gameInfo.turns == preview.turns } + /** + * Checks if the server is alive and sets the [serverFeatureSet] accordingly. + * @return true if the server is alive, false otherwise + */ + fun checkServerStatus(): Boolean { + var statusOk = false + SimpleHttp.sendGetRequest("${UncivGame.Current.settings.multiplayer.server}/isalive") { success, result, _ -> + statusOk = success + if (result.isNotEmpty()) { + featureSet = try { + json().fromJson(ServerFeatureSet::class.java, result) + } catch (ex: Exception) { + Log.error("${UncivGame.Current.settings.multiplayer.server} does not support server feature set") + ServerFeatureSet() + } + } + } + return statusOk + } + + /** + * @return true if the authentication was successful or the server does not support authentication. + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerAuthException if the authentication failed + */ + fun authenticate(password: String?): Boolean { + if (featureSet.authVersion == 0) { + return true + } + + + val settings = UncivGame.Current.settings.multiplayer + + val success = multiplayerFiles.fileStorage().authenticate( + userId=settings.userId, + password=password ?: settings.passwords[settings.server] ?: "" + ) + if (password != null && success) { + settings.passwords[settings.server] = password + } + return success + } + + /** + * @return true if setting the password was successful, false otherwise. + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerAuthException if the authentication failed + */ + fun setPassword(password: String): Boolean { + if ( + featureSet.authVersion > 0 && + multiplayerFiles.fileStorage().setPassword(newPassword = password) + ) { + val settings = UncivGame.Current.settings.multiplayer + settings.passwords[settings.server] = password + return true + } + + return false + } + /** * Checks if [preview1] has a more recent game state than [preview2] */ diff --git a/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt b/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt new file mode 100644 index 0000000000..b053be218a --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt @@ -0,0 +1,15 @@ +package com.unciv.logic.multiplayer + + +/** + * This class is used to store the features of the server. + * + * We use version numbers instead of simple boolean + * to allow for future expansion and backwards compatibility. + * + * Everything is optional, so if a feature is not present, it is assumed to be 0. + * Dropbox does not support anything of this, so it will always be 0. + */ +data class ServerFeatureSet( + val authVersion: Int = 0, +) diff --git a/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt b/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt index ed80a39069..5ec2c70027 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt @@ -89,13 +89,12 @@ object DropBox: FileStorage { return json().fromJson(MetaData::class.java, reader.readText()) } - override fun saveFileData(fileName: String, data: String, overwrite: Boolean) { - val overwriteModeString = if(!overwrite) "" else ""","mode":{".tag":"overwrite"}""" + override fun saveFileData(fileName: String, data: String) { dropboxApi( url="https://content.dropboxapi.com/2/files/upload", data=data, contentType="application/octet-stream", - dropboxApiArg = """{"path":"${getLocalGameLocation(fileName)}"$overwriteModeString}""" + dropboxApiArg = """{"path":"${getLocalGameLocation(fileName)}","mode":{".tag":"overwrite"}}""" ) } @@ -104,6 +103,14 @@ object DropBox: FileStorage { return BufferedReader(InputStreamReader(inputStream)).readText() } + override fun authenticate(userId: String, password: String): Boolean { + throw NotImplementedError() + } + + override fun setPassword(newPassword: String): Boolean { + throw NotImplementedError() + } + fun downloadFile(fileName: String): InputStream { val response = dropboxApi("https://content.dropboxapi.com/2/files/download", contentType = "text/plain", dropboxApiArg = "{\"path\":\"$fileName\"}") diff --git a/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt index 4816fa042e..ff83462139 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt @@ -6,6 +6,7 @@ import java.util.* class FileStorageConflictException : Exception() class FileStorageRateLimitReached(val limitRemainingSeconds: Int) : UncivShowableException("Server limit reached! Please wait for [${limitRemainingSeconds}] seconds") class MultiplayerFileNotFoundException(cause: Throwable?) : UncivShowableException("File could not be found on the multiplayer server", cause) +class MultiplayerAuthException(cause: Throwable?) : UncivShowableException("Authentication failed", cause) interface FileMetaData { fun getLastModified(): Date? @@ -14,9 +15,9 @@ interface FileMetaData { interface FileStorage { /** * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time - * @throws FileStorageConflictException if the file already exists and [overwrite] is false + * @throws MultiplayerAuthException if the authentication failed */ - fun saveFileData(fileName: String, data: String, overwrite: Boolean) + fun saveFileData(fileName: String, data: String) /** * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws FileNotFoundException if the file can't be found @@ -30,6 +31,17 @@ interface FileStorage { /** * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws FileNotFoundException if the file can't be found + * @throws MultiplayerAuthException if the authentication failed */ fun deleteFile(fileName: String) + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerAuthException if the authentication failed + */ + fun authenticate(userId: String, password: String): Boolean + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerAuthException if the authentication failed + */ + fun setPassword(newPassword: String): Boolean } diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt index 55589bb899..1e07c59cb4 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerFiles.kt @@ -5,6 +5,7 @@ import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview import com.unciv.logic.files.UncivFiles +import com.unciv.ui.screens.savescreens.Gzip /** * Allows access to games stored on a server for multiplayer purposes. @@ -23,13 +24,26 @@ class OnlineMultiplayerFiles( fun fileStorage(): FileStorage { val identifier = if (fileStorageIdentifier == null) UncivGame.Current.settings.multiplayer.server else fileStorageIdentifier - return if (identifier == Constants.dropboxMultiplayerServer) DropBox else UncivServerFileStorage(identifier!!) + return if (identifier == Constants.dropboxMultiplayerServer) { + DropBox + } else { + val settings = UncivGame.Current.settings.multiplayer + UncivServerFileStorage.apply { + serverUrl = identifier!! + authHeader = mapOf( + "Authorization" to "Basic ${Gzip.zip(settings.userId)}:${Gzip.zip(settings.passwords[settings.server] ?: "")}" + ) + } + } } - /** @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time */ + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerAuthException if the authentication failed + */ suspend fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) { val zippedGameInfo = UncivFiles.gameInfoToString(gameInfo, forceZip = true) - fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo, true) + fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo) // We upload the preview after the game because otherwise the following race condition will happen: // Current player ends turn -> Uploads Game Preview @@ -48,13 +62,14 @@ class OnlineMultiplayerFiles( * the gameInfo, it is recommended to use tryUploadGame(gameInfo, withPreview = true) * * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws MultiplayerAuthException if the authentication failed * * @see tryUploadGame * @see GameInfo.asPreview */ suspend fun tryUploadGamePreview(gameInfo: GameInfoPreview) { val zippedGameInfo = UncivFiles.gameInfoToString(gameInfo) - fileStorage().saveFileData("${gameInfo.gameId}_Preview", zippedGameInfo, true) + fileStorage().saveFileData("${gameInfo.gameId}_Preview", zippedGameInfo) } /** diff --git a/core/src/com/unciv/logic/multiplayer/storage/SimpleHttp.kt b/core/src/com/unciv/logic/multiplayer/storage/SimpleHttp.kt index 0803d6c1e8..79c72ce2bf 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/SimpleHttp.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/SimpleHttp.kt @@ -16,11 +16,11 @@ import java.nio.charset.Charset private typealias SendRequestCallback = (success: Boolean, result: String, code: Int?)->Unit object SimpleHttp { - fun sendGetRequest(url: String, timeout: Int = 5000, action: SendRequestCallback) { - sendRequest(Net.HttpMethods.GET, url, "", timeout, action) + fun sendGetRequest(url: String, timeout: Int = 5000, header: Map? = null, action: SendRequestCallback) { + sendRequest(Net.HttpMethods.GET, url, "", timeout, header, action) } - fun sendRequest(method: String, url: String, content: String, timeout: Int = 5000, action: SendRequestCallback) { + fun sendRequest(method: String, url: String, content: String, timeout: Int = 5000, header: Map? = null, action: SendRequestCallback) { var uri = URI(url) if (uri.host == null) uri = URI("http://$url") @@ -42,6 +42,10 @@ object SimpleHttp { setRequestProperty("User-Agent", "Unciv/Turn-Checker-GNU-Terry-Pratchett") setRequestProperty("Content-Type", "text/plain") + for ((key, value) in header.orEmpty()) { + setRequestProperty(key, value) + } + try { if (content.isNotEmpty()) { doOutput = true @@ -60,7 +64,7 @@ object SimpleHttp { if (errorStream != null) BufferedReader(InputStreamReader(errorStream)).readText() else t.message!! debug("Returning error message [%s]", errorMessageToReturn) - action(false, errorMessageToReturn, if (errorStream != null) responseCode else null) + action(false, errorMessageToReturn, responseCode) } } } diff --git a/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt index beb5216638..9c1f2dbd5f 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt @@ -1,23 +1,31 @@ package com.unciv.logic.multiplayer.storage import com.badlogic.gdx.Net +import com.unciv.ui.screens.savescreens.Gzip import com.unciv.utils.debug import kotlin.Exception -class UncivServerFileStorage(val serverUrl: String, val timeout: Int = 30000) : FileStorage { - override fun saveFileData(fileName: String, data: String, overwrite: Boolean) { - SimpleHttp.sendRequest(Net.HttpMethods.PUT, fileUrl(fileName), data, timeout) { - success, result, _ -> +object UncivServerFileStorage : FileStorage { + var authHeader: Map? = null + var serverUrl: String = "" + var timeout: Int = 30000 + + override fun saveFileData(fileName: String, data: String) { + SimpleHttp.sendRequest(Net.HttpMethods.PUT, fileUrl(fileName), content=data, timeout=timeout, header=authHeader) { + success, result, code -> if (!success) { debug("Error from UncivServer during save: %s", result) - throw Exception(result) + when (code) { + 401 -> throw MultiplayerAuthException(Exception(result)) + else -> throw Exception(result) + } } } } override fun loadFileData(fileName: String): String { var fileData = "" - SimpleHttp.sendGetRequest(fileUrl(fileName), timeout = timeout){ + SimpleHttp.sendGetRequest(fileUrl(fileName), timeout=timeout, header=authHeader) { success, result, code -> if (!success) { debug("Error from UncivServer during load: %s", result) @@ -37,7 +45,7 @@ class UncivServerFileStorage(val serverUrl: String, val timeout: Int = 30000) : } override fun deleteFile(fileName: String) { - SimpleHttp.sendRequest(Net.HttpMethods.DELETE, fileUrl(fileName), "", timeout) { + SimpleHttp.sendRequest(Net.HttpMethods.DELETE, fileUrl(fileName), content="", timeout=timeout, header=authHeader) { success, result, code -> if (!success) { when (code) { @@ -48,5 +56,44 @@ class UncivServerFileStorage(val serverUrl: String, val timeout: Int = 30000) : } } + override fun authenticate(userId: String, password: String): Boolean { + var authenticated = false + authHeader = mapOf("Authorization" to "Basic ${Gzip.zip(userId)}:${Gzip.zip(password)}") + SimpleHttp.sendGetRequest("$serverUrl/auth", timeout=timeout, header=authHeader) { + success, result, code -> + if (!success) { + debug("Error from UncivServer during authentication: %s", result) + authHeader = null + when (code) { + 401 -> throw MultiplayerAuthException(Exception(result)) + else -> throw Exception(result) + } + } else { + authenticated = true + } + } + return authenticated + } + + override fun setPassword(newPassword: String): Boolean { + if (authHeader == null) + return false + + var setSuccessful = false + SimpleHttp.sendRequest(Net.HttpMethods.PUT, "$serverUrl/auth", content=Gzip.zip(newPassword), timeout=timeout, header=authHeader) { + success, result, code -> + if (!success) { + debug("Error from UncivServer during password set: %s", result) + when (code) { + 401 -> throw MultiplayerAuthException(Exception(result)) + else -> throw Exception(result) + } + } else { + setSuccessful = true + } + } + return setSuccessful + } + private fun fileUrl(fileName: String) = "$serverUrl/files/$fileName" } diff --git a/core/src/com/unciv/models/metadata/GameSettings.kt b/core/src/com/unciv/models/metadata/GameSettings.kt index b87b1007fa..76e228f61d 100644 --- a/core/src/com/unciv/models/metadata/GameSettings.kt +++ b/core/src/com/unciv/models/metadata/GameSettings.kt @@ -220,6 +220,8 @@ enum class LocaleCode(var language: String, var country: String) { class GameSettingsMultiplayer { var userId = "" + var passwords = mutableMapOf() + var userName: String = "" var server = Constants.uncivXyzServer var friendList: MutableList = mutableListOf() var turnCheckerEnabled = true diff --git a/core/src/com/unciv/ui/popups/AuthPopup.kt b/core/src/com/unciv/ui/popups/AuthPopup.kt new file mode 100644 index 0000000000..0945b856ba --- /dev/null +++ b/core/src/com/unciv/ui/popups/AuthPopup.kt @@ -0,0 +1,41 @@ +package com.unciv.ui.popups + +import com.badlogic.gdx.scenes.scene2d.Stage +import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.unciv.UncivGame +import com.unciv.ui.components.UncivTextField +import com.unciv.ui.components.extensions.onClick +import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.screens.basescreen.BaseScreen + +class AuthPopup(stage: Stage, authSuccessful: ((Boolean) -> Unit)? = null) + : Popup(stage) { + + constructor(screen: BaseScreen, authSuccessful: ((Boolean) -> Unit)? = null) : this(screen.stage, authSuccessful) + + init { + val passwordField = UncivTextField.create("Password") + val button = "Authenticate".toTextButton() + val negativeButtonStyle = BaseScreen.skin.get("negative", TextButton.TextButtonStyle::class.java) + + button.onClick { + try { + UncivGame.Current.onlineMultiplayer.authenticate(passwordField.text) + authSuccessful?.invoke(true) + close() + } catch (ex: Exception) { + innerTable.clear() + addGoodSizedLabel("Authentication failed").colspan(2).row() + add(passwordField).colspan(2).growX().pad(16f, 0f, 16f, 0f).row() + addCloseButton(style=negativeButtonStyle) { authSuccessful?.invoke(false) }.growX().padRight(8f) + add(button).growX().padLeft(8f) + return@onClick + } + } + + addGoodSizedLabel("Please enter your server password").colspan(2).row() + add(passwordField).colspan(2).growX().pad(16f, 0f, 16f, 0f).row() + addCloseButton(style=negativeButtonStyle) { authSuccessful?.invoke(false) }.growX().padRight(8f) + add(button).growX().padLeft(8f) + } +} diff --git a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt index c01743dedf..e2be46e33b 100644 --- a/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/popups/options/MultiplayerTab.kt @@ -5,8 +5,10 @@ import com.badlogic.gdx.Gdx import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.TextField import com.unciv.Constants +import com.unciv.UncivGame import com.unciv.logic.multiplayer.OnlineMultiplayer -import com.unciv.logic.multiplayer.storage.SimpleHttp +import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.models.UncivSound import com.unciv.models.metadata.GameSetting import com.unciv.models.metadata.GameSettings @@ -24,6 +26,7 @@ import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toGdxArray import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.popups.AuthPopup import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread import java.time.Duration @@ -145,7 +148,7 @@ private fun addMultiplayerServerOptions( serverIpTable.add("Server address".toLabel().onClick { multiplayerServerTextField.text = Gdx.app.clipboard.contents - }).row() + }).colspan(2).row() multiplayerServerTextField.onChange { val isCustomServer = OnlineMultiplayer.usesCustomServer() connectionToServerButton.isEnabled = isCustomServer @@ -162,26 +165,65 @@ private fun addMultiplayerServerOptions( settings.save() } - serverIpTable.add(multiplayerServerTextField).minWidth(optionsPopup.stageToShowOn.width / 2).growX() - tab.add(serverIpTable).colspan(2).fillX().row() + serverIpTable.add(multiplayerServerTextField) + .minWidth(optionsPopup.stageToShowOn.width / 2) + .colspan(2).growX().padBottom(8f).row() - tab.add("Reset to Dropbox".toTextButton().onClick { + serverIpTable.add("Reset to Dropbox".toTextButton().onClick { multiplayerServerTextField.text = Constants.dropboxMultiplayerServer for (refreshSelect in toUpdate) refreshSelect.update(false) settings.save() - }).colspan(2).row() + }) - tab.add(connectionToServerButton.onClick { + serverIpTable.add(connectionToServerButton.onClick { val popup = Popup(optionsPopup.stageToShowOn).apply { addGoodSizedLabel("Awaiting response...").row() + open(true) } - popup.open(true) - successfullyConnectedToServer(settings) { success, _, _ -> - popup.addGoodSizedLabel(if (success) "Success!" else "Failed!").row() - popup.addCloseButton() + successfullyConnectedToServer { connectionSuccess, authSuccess -> + if (connectionSuccess && authSuccess) { + popup.reuseWith("Success!", true) + } else if (connectionSuccess) { + popup.close() + AuthPopup(optionsPopup.stageToShowOn) { + success -> popup.apply{ + reuseWith(if (success) "Success!" else "Failed!", true) + open(true) + } + }.open(true) + } else { + popup.reuseWith("Failed!", true) + } } - }).colspan(2).row() + }).row() + + if (UncivGame.Current.onlineMultiplayer.serverFeatureSet.authVersion > 0) { + val passwordTextField = UncivTextField.create( + settings.multiplayer.passwords[settings.multiplayer.server] ?: "Password" + ) + val setPasswordButton = "Set password".toTextButton() + + serverIpTable.add("Set password".toLabel()).padTop(16f).colspan(2).row() + serverIpTable.add(passwordTextField).colspan(2).growX().padBottom(8f).row() + + val passwordStatusTable = Table().apply { + add( + if (settings.multiplayer.passwords.containsKey(settings.multiplayer.server)) { + "Your userId is password secured" + } else { + "Set a password to secure your userId" + }.toLabel() + ) + add(setPasswordButton.onClick { + setPassword(passwordTextField.text, optionsPopup) + }).padLeft(16f) + } + + serverIpTable.add(passwordStatusTable).colspan(2).row() + } + + tab.add(serverIpTable).colspan(2).fillX().row() } private fun addTurnCheckerOptions( @@ -216,11 +258,83 @@ private fun addTurnCheckerOptions( return turnCheckerSelect } -private fun successfullyConnectedToServer(settings: GameSettings, action: (Boolean, String, Int?) -> Unit) { +private fun successfullyConnectedToServer(action: (Boolean, Boolean) -> Unit) { Concurrency.run("TestIsAlive") { - SimpleHttp.sendGetRequest("${settings.multiplayer.server}/isalive") { success, result, code -> + try { + val connectionSuccess = UncivGame.Current.onlineMultiplayer.checkServerStatus() + var authSuccess = false + if (connectionSuccess) { + try { + authSuccess = UncivGame.Current.onlineMultiplayer.authenticate(null) + } catch (_: Exception) { + // We ignore the exception here, because we handle the failed auth onGLThread + } + } launchOnGLThread { - action(success, result, code) + action(connectionSuccess, authSuccess) + } + } catch (_: Exception) { + launchOnGLThread { + action(false, false) + } + } + } +} + +private fun setPassword(password: String, optionsPopup: OptionsPopup) { + if (password.isNullOrBlank()) + return + + val popup = Popup(optionsPopup.stageToShowOn).apply { + addGoodSizedLabel("Awaiting response...").row() + open(true) + } + + if (UncivGame.Current.onlineMultiplayer.serverFeatureSet.authVersion == 0) { + popup.reuseWith("This server does not support authentication", true) + return + } + + successfullySetPassword(password) { success, ex -> + if (success) { + popup.reuseWith( + "Password set successfully for server [${optionsPopup.settings.multiplayer.server}]", + true + ) + } else { + if (ex is MultiplayerAuthException) { + AuthPopup(optionsPopup.stageToShowOn) { authSuccess -> + // If auth was successful, try to set password again + if (authSuccess) { + popup.close() + setPassword(password, optionsPopup) + } else { + popup.reuseWith("Failed to set password!", true) + } + }.open(true) + return@successfullySetPassword + } + + val message = when (ex) { + is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds" + else -> "Failed to set password!" + } + + popup.reuseWith(message, true) + } + } +} + +private fun successfullySetPassword(password: String, action: (Boolean, Exception?) -> Unit) { + Concurrency.run("SetPassword") { + try { + val setSuccess = UncivGame.Current.onlineMultiplayer.setPassword(password) + launchOnGLThread { + action(setSuccess, null) + } + } catch (ex: Exception) { + launchOnGLThread { + action(false, ex) } } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/EditMultiplayerGameInfoScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/EditMultiplayerGameInfoScreen.kt index d23aa67fc8..38e2a5cf14 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/EditMultiplayerGameInfoScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/EditMultiplayerGameInfoScreen.kt @@ -3,6 +3,7 @@ package com.unciv.ui.screens.multiplayerscreens import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle import com.unciv.logic.multiplayer.OnlineMultiplayerGame +import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.models.translations.tr import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.ui.popups.ConfirmPopup @@ -15,6 +16,7 @@ import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.popups.AuthPopup import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread @@ -116,6 +118,15 @@ class EditMultiplayerGameInfoScreen(val multiplayerGame: OnlineMultiplayerGame) } } } catch (ex: Exception) { + if (ex is MultiplayerAuthException) { + launchOnGLThread { + AuthPopup(this@EditMultiplayerGameInfoScreen) { + success -> if (success) resign(multiplayerGame) + }.open(true) + } + return@runOnNonDaemonThreadPool + } + val (message) = LoadGameScreen.getLoadExceptionMessage(ex) launchOnGLThread { popup.reuseWith(message, true) diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt index 6580b3c406..b694352189 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt @@ -22,6 +22,7 @@ import com.unciv.logic.event.EventBus import com.unciv.logic.map.MapVisualization import com.unciv.logic.multiplayer.MultiplayerGameUpdated import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.logic.trade.TradeEvaluation import com.unciv.models.TutorialTrigger import com.unciv.models.ruleset.tile.ResourceType @@ -35,6 +36,7 @@ import com.unciv.ui.components.extensions.setFontSize import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.AuthPopup import com.unciv.ui.popups.Popup import com.unciv.ui.popups.ToastPopup import com.unciv.ui.popups.hasOpenPopups @@ -624,16 +626,25 @@ class WorldScreen( try { game.onlineMultiplayer.updateGame(gameInfoClone) } catch (ex: Exception) { - val message = when (ex) { - is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds" - else -> "Could not upload game!" - } - launchOnGLThread { // Since we're changing the UI, that should be done on the main thread - val cantUploadNewGamePopup = Popup(this@WorldScreen) - cantUploadNewGamePopup.addGoodSizedLabel(message).row() - cantUploadNewGamePopup.addCloseButton() - cantUploadNewGamePopup.open() + if (ex is MultiplayerAuthException) { + launchOnGLThread { + AuthPopup(this@WorldScreen) { + success -> if (success) nextTurn() + }.open(true) + } + } else { + val message = when (ex) { + is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds" + else -> "Could not upload game!" + } + launchOnGLThread { // Since we're changing the UI, that should be done on the main thread + val cantUploadNewGamePopup = Popup(this@WorldScreen) + cantUploadNewGamePopup.addGoodSizedLabel(message).row() + cantUploadNewGamePopup.addCloseButton() + cantUploadNewGamePopup.open() + } } + this@WorldScreen.isPlayersTurn = true // Since we couldn't push the new game clone, then it's like we never clicked the "next turn" button this@WorldScreen.shouldUpdate = true return@runOnNonDaemonThreadPool diff --git a/docs/Developers/Building-Locally.md b/docs/Developers/Building-Locally.md index 07059bbe9b..2df7d0aa94 100644 --- a/docs/Developers/Building-Locally.md +++ b/docs/Developers/Building-Locally.md @@ -78,7 +78,7 @@ The simple multiplayer host included in the sources can be set up to debug or ru - In Android Studio, Run > Edit configurations. - Click "+" to add a new configuration - Choose "Application" and name the config, e.g. "UncivServer" - - Set the module to `Unciv.server.main` (`Unciv.server` for Studio versions Bumblebee or below), main class to `com.unciv.app.server.DesktopLauncher` and `/android/assets/` as the Working directory, OK to close the window. + - Set the module to `Unciv.server.main` (`Unciv.server` for Studio versions Bumblebee or below), main class to `com.unciv.app.server.UncivServer` and `/android/assets/` as the Working directory, OK to close the window. - Select the UncivServer configuration and click the green arrow button to run! Or start a debug session as above. To build a jar file, refer to [Without Android Studio](#Without-Android-Studio) and replace 'desktop' with 'server'. That is, run `./gradlew server:dist` and when it's done look for /server/build/libs/UncivServer.jar diff --git a/server/src/com/unciv/app/server/UncivServer.kt b/server/src/com/unciv/app/server/UncivServer.kt index 94190b5913..7d4ef34b6b 100644 --- a/server/src/com/unciv/app/server/UncivServer.kt +++ b/server/src/com/unciv/app/server/UncivServer.kt @@ -2,11 +2,14 @@ package com.unciv.app.server import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.parameters.types.restrictTo import io.ktor.application.* +import io.ktor.features.* import io.ktor.http.* +import io.ktor.request.* import io.ktor.response.* import io.ktor.routing.* import io.ktor.server.engine.* @@ -15,6 +18,7 @@ import io.ktor.utils.io.jvm.javaio.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File +import java.util.concurrent.TimeUnit internal object UncivServer { @@ -35,23 +39,92 @@ private class UncivServerRunner : CliktCommand() { help = "Multiplayer file's folder" ).default("MultiplayerFiles") + private val authV1Enabled by option( + "-a", "-auth", + envvar = "UncivServerAuth", + help = "Enable Authentication" + ).flag("-no-auth", default = false) + override fun run() { serverRun(port, folder) } + // region Auth + private val authMap: MutableMap = mutableMapOf() + + private fun loadAuthFile() { + val authFile = File("server.auth") + if (!authFile.exists()) { + echo("No server.auth file found, creating one") + authFile.createNewFile() + } else { + authMap.putAll( + authFile.readLines().map { it.split(":") }.associate { it[0] to it[1] } + ) + } + } + + private fun saveAuthFile() { + val authFile = File("server.auth") + authFile.writeText(authMap.map { "${it.key}:${it.value}" }.joinToString("\n")) + } + + /** + * @return true if either auth is disabled, no password is set for the current player, + * or the password is correct + */ + private fun validateGameAccess(file: File, authString: String?): Boolean { + if (!authV1Enabled || !file.exists()) + return true + + // If auth is enabled, an auth string is required + if (authString == null || !authString.startsWith("Basic ")) + return false + + // Extract the user id and password from the auth string + val (userId, password) = authString.drop(6).split(":") + + if (authMap[userId] == null || authMap[userId] == password) + return true + + return false + + // TODO Check if the user is the current player and validate its password this requires decoding the game file + } + + private fun validateAuth(authString: String?): Boolean { + if (!authV1Enabled) + return true + // If auth is enabled a auth string is required + if (authString == null || !authString.startsWith("Basic ")) + return false + + val (userId, password) = authString.drop(6).split(":") + if (authMap[userId] == null || authMap[userId] == password) + return true + return false + } + // endregion Auth + private fun serverRun(serverPort: Int, fileFolderName: String) { val portStr: String = if (serverPort == 80) "" else ":$serverPort" echo("Starting UncivServer for ${File(fileFolderName).absolutePath} on http://localhost$portStr") - embeddedServer(Netty, port = serverPort) { + val server = embeddedServer(Netty, port = serverPort) { routing { get("/isalive") { log.info("Received isalive request from ${call.request.local.remoteHost}") - call.respondText("true") + call.respondText("{authVersion: ${if (authV1Enabled) "1" else "0"}}") } put("/files/{fileName}") { val fileName = call.parameters["fileName"] ?: throw Exception("No fileName!") log.info("Receiving file: ${fileName}") val file = File(fileFolderName, fileName) + + if (!validateGameAccess(file, call.request.headers["Authorization"])) { + call.respond(HttpStatusCode.Unauthorized) + return@put + } + withContext(Dispatchers.IO) { file.outputStream().use { call.request.receiveChannel().toInputStream().copyTo(it) @@ -69,19 +142,52 @@ private class UncivServerRunner : CliktCommand() { return@get } val fileText = withContext(Dispatchers.IO) { file.readText() } + call.respondText(fileText) } - delete("/files/{fileName}") { - val fileName = call.parameters["fileName"] ?: throw Exception("No fileName!") - log.info("Deleting file: $fileName") - val file = File(fileFolderName, fileName) - if (!file.exists()) { - call.respond(HttpStatusCode.NotFound, "File does not exist") - return@delete + if (authV1Enabled) { + get("/auth") { + log.info("Received auth request from ${call.request.local.remoteHost}") + val authHeader = call.request.headers["Authorization"] + if (validateAuth(authHeader)) { + call.respond(HttpStatusCode.OK) + } else { + call.respond(HttpStatusCode.Unauthorized) + } + } + put("/auth") { + log.info("Received auth password set from ${call.request.local.remoteHost}") + val authHeader = call.request.headers["Authorization"] + if (validateAuth(authHeader)) { + val userId = authHeader?.drop(6)?.split(":")?.get(0) + if (userId != null) { + authMap[userId] = call.receiveText() + call.respond(HttpStatusCode.OK) + } else { + call.respond(HttpStatusCode.BadRequest) + } + } else { + call.respond(HttpStatusCode.Unauthorized) + } } - file.delete() } } - }.start(wait = true) + }.start(wait = false) + + if (authV1Enabled) { + loadAuthFile() + } + + echo("Server running on http://localhost$portStr! Press Ctrl+C to stop") + Runtime.getRuntime().addShutdownHook(Thread { + echo("Shutting down server...") + + if (authV1Enabled) { + saveAuthFile() + } + + server.stop(1, 5, TimeUnit.SECONDS) + }) + Thread.currentThread().join() } }