Added password authentication as server feature (#8716)

* Added authenticate and setPassword

* Added AuthPopup

* Added auth endpoint to UncivServer

* Fixed merge confict

* Added close button to AuthPopup

+ Fixed crash if no server is available

* Added entries to template properties

* Fixed setting password does not work

if authentication is required

+ Added server check on game startup in case the server has changed its feature set

* Added support for different passwords

on different servers

+ Cleanup for MultiplayerTab

* Added current password as hint

+ removed character hiding in auth popup
+ added popup to indicate auth success on connection check
This commit is contained in:
Leonard Günther 2023-02-25 19:42:36 +01:00 committed by GitHub
parent 5d80f50d93
commit a3f511efd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 527 additions and 56 deletions

View File

@ -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

View File

@ -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

View File

@ -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<FileHandle, OnlineMultiplayerGame> = Collections.synchronizedMap(mutableMapOf())
@ -50,6 +55,7 @@ class OnlineMultiplayer {
private val lastCurGameRefresh: AtomicReference<Instant?> = AtomicReference()
val games: Set<OnlineMultiplayerGame> get() = savedGames.values.toSet()
val serverFeatureSet: ServerFeatureSet get() = featureSet
init {
flow<Unit> {
@ -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]
*/

View File

@ -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,
)

View File

@ -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\"}")

View File

@ -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
}

View File

@ -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)
}
/**

View File

@ -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<String, String>? = 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<String, String>? = 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)
}
}
}

View File

@ -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<String, String>? = 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"
}

View File

@ -220,6 +220,8 @@ enum class LocaleCode(var language: String, var country: String) {
class GameSettingsMultiplayer {
var userId = ""
var passwords = mutableMapOf<String, String>()
var userName: String = ""
var server = Constants.uncivXyzServer
var friendList: MutableList<FriendList.Friend> = mutableListOf()
var turnCheckerEnabled = true

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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

View File

@ -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 `<repo_folder>/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 `<repo_folder>/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

View File

@ -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<String, String> = 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()
}
}