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 = Server limit reached! Please wait for [time] seconds =
File could not be found on the multiplayer server = File could not be found on the multiplayer server =
Unhandled problem, [errorMessage] = 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 # Save game menu

View File

@ -197,6 +197,13 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
onlineMultiplayer = OnlineMultiplayer() 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.resetAtlases()
ImageGetter.setNewRuleset(ImageGetter.ruleset) // This needs to come after the settings, since we may have default visual mods 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 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.badlogic.gdx.files.FileHandle
import com.unciv.Constants import com.unciv.Constants
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.json.json
import com.unciv.logic.GameInfo import com.unciv.logic.GameInfo
import com.unciv.logic.GameInfoPreview import com.unciv.logic.GameInfoPreview
import com.unciv.logic.civilization.NotificationCategory import com.unciv.logic.civilization.NotificationCategory
import com.unciv.logic.civilization.PlayerType import com.unciv.logic.civilization.PlayerType
import com.unciv.logic.event.EventBus import com.unciv.logic.event.EventBus
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached 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.MultiplayerFileNotFoundException
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerFiles import com.unciv.logic.multiplayer.storage.OnlineMultiplayerFiles
import com.unciv.ui.components.extensions.isLargerThan 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.Concurrency
import com.unciv.utils.concurrency.Dispatcher import com.unciv.utils.concurrency.Dispatcher
import com.unciv.utils.concurrency.launchOnThreadPool import com.unciv.utils.concurrency.launchOnThreadPool
@ -42,6 +46,7 @@ private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60)
class OnlineMultiplayer { class OnlineMultiplayer {
private val files = UncivGame.Current.files private val files = UncivGame.Current.files
private val multiplayerFiles = OnlineMultiplayerFiles() private val multiplayerFiles = OnlineMultiplayerFiles()
private var featureSet = ServerFeatureSet()
private val savedGames: MutableMap<FileHandle, OnlineMultiplayerGame> = Collections.synchronizedMap(mutableMapOf()) private val savedGames: MutableMap<FileHandle, OnlineMultiplayerGame> = Collections.synchronizedMap(mutableMapOf())
@ -50,6 +55,7 @@ class OnlineMultiplayer {
private val lastCurGameRefresh: AtomicReference<Instant?> = AtomicReference() private val lastCurGameRefresh: AtomicReference<Instant?> = AtomicReference()
val games: Set<OnlineMultiplayerGame> get() = savedGames.values.toSet() val games: Set<OnlineMultiplayerGame> get() = savedGames.values.toSet()
val serverFeatureSet: ServerFeatureSet get() = featureSet
init { init {
flow<Unit> { 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 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 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 * @return false if it's not the user's turn and thus resigning did not happen
*/ */
suspend fun resign(game: OnlineMultiplayerGame): Boolean { 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 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 MultiplayerFileNotFoundException if the file can't be found
* @throws MultiplayerAuthException if the authentication failed
*/ */
suspend fun updateGame(gameInfo: GameInfo) { suspend fun updateGame(gameInfo: GameInfo) {
debug("Updating remote game %s", gameInfo.gameId) debug("Updating remote game %s", gameInfo.gameId)
@ -323,6 +331,67 @@ class OnlineMultiplayer {
&& gameInfo.turns == preview.turns && 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] * 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()) return json().fromJson(MetaData::class.java, reader.readText())
} }
override fun saveFileData(fileName: String, data: String, overwrite: Boolean) { override fun saveFileData(fileName: String, data: String) {
val overwriteModeString = if(!overwrite) "" else ""","mode":{".tag":"overwrite"}"""
dropboxApi( dropboxApi(
url="https://content.dropboxapi.com/2/files/upload", url="https://content.dropboxapi.com/2/files/upload",
data=data, data=data,
contentType="application/octet-stream", 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() 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 { fun downloadFile(fileName: String): InputStream {
val response = dropboxApi("https://content.dropboxapi.com/2/files/download", val response = dropboxApi("https://content.dropboxapi.com/2/files/download",
contentType = "text/plain", dropboxApiArg = "{\"path\":\"$fileName\"}") contentType = "text/plain", dropboxApiArg = "{\"path\":\"$fileName\"}")

View File

@ -6,6 +6,7 @@ import java.util.*
class FileStorageConflictException : Exception() class FileStorageConflictException : Exception()
class FileStorageRateLimitReached(val limitRemainingSeconds: Int) : UncivShowableException("Server limit reached! Please wait for [${limitRemainingSeconds}] seconds") 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 MultiplayerFileNotFoundException(cause: Throwable?) : UncivShowableException("File could not be found on the multiplayer server", cause)
class MultiplayerAuthException(cause: Throwable?) : UncivShowableException("Authentication failed", cause)
interface FileMetaData { interface FileMetaData {
fun getLastModified(): Date? fun getLastModified(): Date?
@ -14,9 +15,9 @@ interface FileMetaData {
interface FileStorage { interface FileStorage {
/** /**
* @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 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 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 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 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 FileNotFoundException if the file can't be found
* @throws MultiplayerAuthException if the authentication failed
*/ */
fun deleteFile(fileName: String) 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.GameInfo
import com.unciv.logic.GameInfoPreview import com.unciv.logic.GameInfoPreview
import com.unciv.logic.files.UncivFiles import com.unciv.logic.files.UncivFiles
import com.unciv.ui.screens.savescreens.Gzip
/** /**
* Allows access to games stored on a server for multiplayer purposes. * Allows access to games stored on a server for multiplayer purposes.
@ -23,13 +24,26 @@ class OnlineMultiplayerFiles(
fun fileStorage(): FileStorage { fun fileStorage(): FileStorage {
val identifier = if (fileStorageIdentifier == null) UncivGame.Current.settings.multiplayer.server else fileStorageIdentifier 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) { suspend fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) {
val zippedGameInfo = UncivFiles.gameInfoToString(gameInfo, forceZip = true) 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: // We upload the preview after the game because otherwise the following race condition will happen:
// Current player ends turn -> Uploads Game Preview // Current player ends turn -> Uploads Game Preview
@ -48,13 +62,14 @@ class OnlineMultiplayerFiles(
* the gameInfo, it is recommended to use tryUploadGame(gameInfo, withPreview = true) * 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 FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
* @throws MultiplayerAuthException if the authentication failed
* *
* @see tryUploadGame * @see tryUploadGame
* @see GameInfo.asPreview * @see GameInfo.asPreview
*/ */
suspend fun tryUploadGamePreview(gameInfo: GameInfoPreview) { suspend fun tryUploadGamePreview(gameInfo: GameInfoPreview) {
val zippedGameInfo = UncivFiles.gameInfoToString(gameInfo) 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 private typealias SendRequestCallback = (success: Boolean, result: String, code: Int?)->Unit
object SimpleHttp { object SimpleHttp {
fun sendGetRequest(url: String, timeout: Int = 5000, action: SendRequestCallback) { fun sendGetRequest(url: String, timeout: Int = 5000, header: Map<String, String>? = null, action: SendRequestCallback) {
sendRequest(Net.HttpMethods.GET, url, "", timeout, action) 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) var uri = URI(url)
if (uri.host == null) uri = URI("http://$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("User-Agent", "Unciv/Turn-Checker-GNU-Terry-Pratchett")
setRequestProperty("Content-Type", "text/plain") setRequestProperty("Content-Type", "text/plain")
for ((key, value) in header.orEmpty()) {
setRequestProperty(key, value)
}
try { try {
if (content.isNotEmpty()) { if (content.isNotEmpty()) {
doOutput = true doOutput = true
@ -60,7 +64,7 @@ object SimpleHttp {
if (errorStream != null) BufferedReader(InputStreamReader(errorStream)).readText() if (errorStream != null) BufferedReader(InputStreamReader(errorStream)).readText()
else t.message!! else t.message!!
debug("Returning error message [%s]", errorMessageToReturn) 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 package com.unciv.logic.multiplayer.storage
import com.badlogic.gdx.Net import com.badlogic.gdx.Net
import com.unciv.ui.screens.savescreens.Gzip
import com.unciv.utils.debug import com.unciv.utils.debug
import kotlin.Exception import kotlin.Exception
class UncivServerFileStorage(val serverUrl: String, val timeout: Int = 30000) : FileStorage { object UncivServerFileStorage : FileStorage {
override fun saveFileData(fileName: String, data: String, overwrite: Boolean) { var authHeader: Map<String, String>? = null
SimpleHttp.sendRequest(Net.HttpMethods.PUT, fileUrl(fileName), data, timeout) { var serverUrl: String = ""
success, result, _ -> 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) { if (!success) {
debug("Error from UncivServer during save: %s", result) 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 { override fun loadFileData(fileName: String): String {
var fileData = "" var fileData = ""
SimpleHttp.sendGetRequest(fileUrl(fileName), timeout = timeout){ SimpleHttp.sendGetRequest(fileUrl(fileName), timeout=timeout, header=authHeader) {
success, result, code -> success, result, code ->
if (!success) { if (!success) {
debug("Error from UncivServer during load: %s", result) 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) { 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 -> success, result, code ->
if (!success) { if (!success) {
when (code) { 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" 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 { class GameSettingsMultiplayer {
var userId = "" var userId = ""
var passwords = mutableMapOf<String, String>()
var userName: String = ""
var server = Constants.uncivXyzServer var server = Constants.uncivXyzServer
var friendList: MutableList<FriendList.Friend> = mutableListOf() var friendList: MutableList<FriendList.Friend> = mutableListOf()
var turnCheckerEnabled = true 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.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.Constants import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.multiplayer.OnlineMultiplayer 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.UncivSound
import com.unciv.models.metadata.GameSetting import com.unciv.models.metadata.GameSetting
import com.unciv.models.metadata.GameSettings 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.toGdxArray
import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.popups.AuthPopup
import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.launchOnGLThread import com.unciv.utils.concurrency.launchOnGLThread
import java.time.Duration import java.time.Duration
@ -145,7 +148,7 @@ private fun addMultiplayerServerOptions(
serverIpTable.add("Server address".toLabel().onClick { serverIpTable.add("Server address".toLabel().onClick {
multiplayerServerTextField.text = Gdx.app.clipboard.contents multiplayerServerTextField.text = Gdx.app.clipboard.contents
}).row() }).colspan(2).row()
multiplayerServerTextField.onChange { multiplayerServerTextField.onChange {
val isCustomServer = OnlineMultiplayer.usesCustomServer() val isCustomServer = OnlineMultiplayer.usesCustomServer()
connectionToServerButton.isEnabled = isCustomServer connectionToServerButton.isEnabled = isCustomServer
@ -162,26 +165,65 @@ private fun addMultiplayerServerOptions(
settings.save() settings.save()
} }
serverIpTable.add(multiplayerServerTextField).minWidth(optionsPopup.stageToShowOn.width / 2).growX() serverIpTable.add(multiplayerServerTextField)
tab.add(serverIpTable).colspan(2).fillX().row() .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 multiplayerServerTextField.text = Constants.dropboxMultiplayerServer
for (refreshSelect in toUpdate) refreshSelect.update(false) for (refreshSelect in toUpdate) refreshSelect.update(false)
settings.save() settings.save()
}).colspan(2).row() })
tab.add(connectionToServerButton.onClick { serverIpTable.add(connectionToServerButton.onClick {
val popup = Popup(optionsPopup.stageToShowOn).apply { val popup = Popup(optionsPopup.stageToShowOn).apply {
addGoodSizedLabel("Awaiting response...").row() addGoodSizedLabel("Awaiting response...").row()
open(true)
} }
popup.open(true)
successfullyConnectedToServer(settings) { success, _, _ -> successfullyConnectedToServer { connectionSuccess, authSuccess ->
popup.addGoodSizedLabel(if (success) "Success!" else "Failed!").row() if (connectionSuccess && authSuccess) {
popup.addCloseButton() 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( private fun addTurnCheckerOptions(
@ -216,11 +258,83 @@ private fun addTurnCheckerOptions(
return turnCheckerSelect return turnCheckerSelect
} }
private fun successfullyConnectedToServer(settings: GameSettings, action: (Boolean, String, Int?) -> Unit) { private fun successfullyConnectedToServer(action: (Boolean, Boolean) -> Unit) {
Concurrency.run("TestIsAlive") { 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 { 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.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle import com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle
import com.unciv.logic.multiplayer.OnlineMultiplayerGame import com.unciv.logic.multiplayer.OnlineMultiplayerGame
import com.unciv.logic.multiplayer.storage.MultiplayerAuthException
import com.unciv.models.translations.tr import com.unciv.models.translations.tr
import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.ui.screens.pickerscreens.PickerScreen
import com.unciv.ui.popups.ConfirmPopup 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.onClick
import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.popups.AuthPopup
import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.launchOnGLThread import com.unciv.utils.concurrency.launchOnGLThread
@ -116,6 +118,15 @@ class EditMultiplayerGameInfoScreen(val multiplayerGame: OnlineMultiplayerGame)
} }
} }
} catch (ex: Exception) { } 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) val (message) = LoadGameScreen.getLoadExceptionMessage(ex)
launchOnGLThread { launchOnGLThread {
popup.reuseWith(message, true) 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.map.MapVisualization
import com.unciv.logic.multiplayer.MultiplayerGameUpdated import com.unciv.logic.multiplayer.MultiplayerGameUpdated
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.logic.multiplayer.storage.MultiplayerAuthException
import com.unciv.logic.trade.TradeEvaluation import com.unciv.logic.trade.TradeEvaluation
import com.unciv.models.TutorialTrigger import com.unciv.models.TutorialTrigger
import com.unciv.models.ruleset.tile.ResourceType 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.toLabel
import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.images.ImageGetter import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.AuthPopup
import com.unciv.ui.popups.Popup import com.unciv.ui.popups.Popup
import com.unciv.ui.popups.ToastPopup import com.unciv.ui.popups.ToastPopup
import com.unciv.ui.popups.hasOpenPopups import com.unciv.ui.popups.hasOpenPopups
@ -624,16 +626,25 @@ class WorldScreen(
try { try {
game.onlineMultiplayer.updateGame(gameInfoClone) game.onlineMultiplayer.updateGame(gameInfoClone)
} catch (ex: Exception) { } catch (ex: Exception) {
val message = when (ex) { if (ex is MultiplayerAuthException) {
is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds" launchOnGLThread {
else -> "Could not upload game!" AuthPopup(this@WorldScreen) {
} success -> if (success) nextTurn()
launchOnGLThread { // Since we're changing the UI, that should be done on the main thread }.open(true)
val cantUploadNewGamePopup = Popup(this@WorldScreen) }
cantUploadNewGamePopup.addGoodSizedLabel(message).row() } else {
cantUploadNewGamePopup.addCloseButton() val message = when (ex) {
cantUploadNewGamePopup.open() 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.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 this@WorldScreen.shouldUpdate = true
return@runOnNonDaemonThreadPool 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. - In Android Studio, Run > Edit configurations.
- Click "+" to add a new configuration - Click "+" to add a new configuration
- Choose "Application" and name the config, e.g. "UncivServer" - 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. - 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 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.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.default 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.options.option
import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.parameters.types.int
import com.github.ajalt.clikt.parameters.types.restrictTo import com.github.ajalt.clikt.parameters.types.restrictTo
import io.ktor.application.* import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.* import io.ktor.response.*
import io.ktor.routing.* import io.ktor.routing.*
import io.ktor.server.engine.* import io.ktor.server.engine.*
@ -15,6 +18,7 @@ import io.ktor.utils.io.jvm.javaio.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit
internal object UncivServer { internal object UncivServer {
@ -35,23 +39,92 @@ private class UncivServerRunner : CliktCommand() {
help = "Multiplayer file's folder" help = "Multiplayer file's folder"
).default("MultiplayerFiles") ).default("MultiplayerFiles")
private val authV1Enabled by option(
"-a", "-auth",
envvar = "UncivServerAuth",
help = "Enable Authentication"
).flag("-no-auth", default = false)
override fun run() { override fun run() {
serverRun(port, folder) 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) { private fun serverRun(serverPort: Int, fileFolderName: String) {
val portStr: String = if (serverPort == 80) "" else ":$serverPort" val portStr: String = if (serverPort == 80) "" else ":$serverPort"
echo("Starting UncivServer for ${File(fileFolderName).absolutePath} on http://localhost$portStr") echo("Starting UncivServer for ${File(fileFolderName).absolutePath} on http://localhost$portStr")
embeddedServer(Netty, port = serverPort) { val server = embeddedServer(Netty, port = serverPort) {
routing { routing {
get("/isalive") { get("/isalive") {
log.info("Received isalive request from ${call.request.local.remoteHost}") log.info("Received isalive request from ${call.request.local.remoteHost}")
call.respondText("true") call.respondText("{authVersion: ${if (authV1Enabled) "1" else "0"}}")
} }
put("/files/{fileName}") { put("/files/{fileName}") {
val fileName = call.parameters["fileName"] ?: throw Exception("No fileName!") val fileName = call.parameters["fileName"] ?: throw Exception("No fileName!")
log.info("Receiving file: ${fileName}") log.info("Receiving file: ${fileName}")
val file = File(fileFolderName, fileName) val file = File(fileFolderName, fileName)
if (!validateGameAccess(file, call.request.headers["Authorization"])) {
call.respond(HttpStatusCode.Unauthorized)
return@put
}
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
file.outputStream().use { file.outputStream().use {
call.request.receiveChannel().toInputStream().copyTo(it) call.request.receiveChannel().toInputStream().copyTo(it)
@ -69,19 +142,52 @@ private class UncivServerRunner : CliktCommand() {
return@get return@get
} }
val fileText = withContext(Dispatchers.IO) { file.readText() } val fileText = withContext(Dispatchers.IO) { file.readText() }
call.respondText(fileText) call.respondText(fileText)
} }
delete("/files/{fileName}") { if (authV1Enabled) {
val fileName = call.parameters["fileName"] ?: throw Exception("No fileName!") get("/auth") {
log.info("Deleting file: $fileName") log.info("Received auth request from ${call.request.local.remoteHost}")
val file = File(fileFolderName, fileName) val authHeader = call.request.headers["Authorization"]
if (!file.exists()) { if (validateAuth(authHeader)) {
call.respond(HttpStatusCode.NotFound, "File does not exist") call.respond(HttpStatusCode.OK)
return@delete } 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()
} }
} }