mirror of
https://github.com/yairm210/Unciv.git
synced 2025-09-22 10:54:19 -04:00
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:
parent
5d80f50d93
commit
a3f511efd2
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
*/
|
||||
|
15
core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt
Normal file
15
core/src/com/unciv/logic/multiplayer/ServerFeatureSet.kt
Normal 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,
|
||||
)
|
@ -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\"}")
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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
|
||||
|
41
core/src/com/unciv/ui/popups/AuthPopup.kt
Normal file
41
core/src/com/unciv/ui/popups/AuthPopup.kt
Normal 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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user