improve microsoft accounts

This commit is contained in:
Bixilon 2022-04-18 19:42:04 +02:00
parent feaebc9e16
commit dba5c12b76
No known key found for this signature in database
GPG Key ID: 5CAD791931B09AC4
25 changed files with 655 additions and 128 deletions

View File

@ -63,4 +63,8 @@ abstract class Account(
}
check(latch, clientToken)
}
fun save() {
println("ToDo") // ToDo
}
}

View File

@ -13,9 +13,9 @@
package de.bixilon.minosoft.data.accounts.types.microsoft
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import de.bixilon.kutil.latch.CountUpAndDownLatch
import de.bixilon.kutil.time.TimeUtil
import de.bixilon.minosoft.data.accounts.Account
import de.bixilon.minosoft.data.accounts.AccountStates
import de.bixilon.minosoft.data.player.properties.PlayerProperties
@ -30,34 +30,27 @@ import java.util.*
class MicrosoftAccount(
val uuid: UUID,
username: String,
@field:JsonProperty private val authorizationToken: String,
@field:JsonProperty private var msa: MicrosoftTokens,
@field:JsonProperty private var minecraft: MinecraftTokens,
override val properties: PlayerProperties?,
) : Account(username) {
@Transient @JsonIgnore var accessToken: String? = null
override val id: String = uuid.toString()
override val type: ResourceLocation = RESOURCE_LOCATION
@Synchronized
override fun join(serverId: String) {
AccountUtil.joinMojangServer(username, accessToken!!, uuid, serverId)
tryCheck(null, "null")
AccountUtil.joinMojangServer(username, minecraft.accessToken, uuid, serverId)
}
override fun logout(clientToken: String) = Unit
@Synchronized
override fun check(latch: CountUpAndDownLatch?, @Nullable clientToken: String) {
if (accessToken != null) {
return
}
val innerLatch = CountUpAndDownLatch(3, latch)
val innerLatch = CountUpAndDownLatch(1, latch)
try {
state = AccountStates.REFRESHING
val (xboxLiveToken, userHash) = MicrosoftOAuthUtils.getXboxLiveToken(authorizationToken)
innerLatch.dec()
val xstsToken = MicrosoftOAuthUtils.getXSTSToken(xboxLiveToken)
innerLatch.dec()
accessToken = MicrosoftOAuthUtils.getMinecraftBearerAccessToken(userHash, xstsToken)
this.error = null
checkMinecraftToken(innerLatch)
innerLatch.dec()
state = AccountStates.WORKING
} catch (exception: Throwable) {
@ -68,6 +61,71 @@ class MicrosoftAccount(
}
}
override fun tryCheck(latch: CountUpAndDownLatch?, clientToken: String) {
if (state == AccountStates.CHECKING || state == AccountStates.REFRESHING) {
// already checking
return
}
if (minecraft.expires >= TimeUtil.time / 1000) {
return check(latch, "null")
}
if (state == AccountStates.WORKING) {
// Nothing to do
return
}
check(latch, clientToken)
}
private fun refreshMicrosoftToken(latch: CountUpAndDownLatch?) {
state = AccountStates.REFRESHING
latch?.inc()
msa = MicrosoftOAuthUtils.refreshToken(msa).saveTokens()
latch?.dec()
}
private fun refreshMinecraftToken(latch: CountUpAndDownLatch?) {
state = AccountStates.REFRESHING
val time = TimeUtil.time / 1000
if (time >= msa.expires) {
// token expired
refreshMicrosoftToken(latch)
}
try {
latch?.let { it.count += 3 }
val xboxLiveToken = MicrosoftOAuthUtils.getXboxLiveToken(msa)
latch?.dec()
val xstsToken = MicrosoftOAuthUtils.getXSTSToken(xboxLiveToken)
latch?.dec()
minecraft = MicrosoftOAuthUtils.getMinecraftBearerAccessToken(xboxLiveToken, xstsToken).saveTokens()
latch?.dec()
} catch (exception: Throwable) {
exception.printStackTrace()
refreshMicrosoftToken(latch)
}
save()
}
private fun checkMinecraftToken(latch: CountUpAndDownLatch?) {
state = AccountStates.CHECKING
val time = TimeUtil.time / 1000
if (time >= minecraft.expires) {
// token expired
refreshMinecraftToken(latch)
}
try {
latch?.inc()
AccountUtil.fetchMinecraftProfile(minecraft)
latch?.dec()
state = AccountStates.WORKING
} catch (exception: Throwable) {
exception.printStackTrace()
refreshMinecraftToken(latch)
}
}
override fun toString(): String {
return "MicrosoftAccount{$username}"
}

View File

@ -0,0 +1,20 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.data.accounts.types.microsoft
data class MicrosoftTokens(
val accessToken: String,
val refreshToken: String,
val expires: Long,
)

View File

@ -1,6 +1,6 @@
/*
* Minosoft
* Copyright (C) 2021 Moritz Zwerger
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
@ -11,10 +11,9 @@
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.data.accounts
package de.bixilon.minosoft.data.accounts.types.microsoft
data class MojangAccountInfo(
val id: String,
val name: String,
// ToDo: Skins, Capes
data class MinecraftTokens(
val accessToken: String,
val expires: Long,
)

View File

@ -97,6 +97,7 @@ class MojangAccount(
refreshed = true
state = AccountStates.WORKING
save()
Log.log(LogMessageType.AUTHENTICATION, LogLevels.VERBOSE) { "Mojang account refresh successful (username=$username)" }
}

View File

@ -1,6 +1,6 @@
/*
* Minosoft
* Copyright (C) 2021 Moritz Zwerger
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
@ -16,10 +16,10 @@ package de.bixilon.minosoft.data.text
import de.bixilon.kutil.enums.EnumUtil
import de.bixilon.kutil.enums.ValuesEnum
enum class URLProtocols(val prefix: String, val restricted: Boolean = false) {
HTTP("http://"),
HTTPS("https://"),
FILE("file:", true),
enum class URLProtocols(val protocol: String, val prefix: String, val restricted: Boolean = false) {
HTTP("http", "http://"),
HTTPS("https", "https://"),
FILE("file", "file:", true),
;
companion object : ValuesEnum<URLProtocols> {

View File

@ -253,8 +253,8 @@ class AccountController : EmbeddedJavaFXController<Pane>() {
"minosoft:main.account.account_info.uuid".toResourceLocation() to { it.uuid },
),
icon = FontAwesomeBrands.MICROSOFT,
addHandler = { MicrosoftAddController(it).show() },
refreshHandler = { controller, account -> MicrosoftAddController(controller, account).show() }
addHandler = { MicrosoftAddController(it).request() },
refreshHandler = { controller, account -> MicrosoftAddController(controller, account).request() }
),
ErosAccountType<OfflineAccount>(
resourceLocation = OfflineAccount.RESOURCE_LOCATION,

View File

@ -13,41 +13,81 @@
package de.bixilon.minosoft.gui.eros.main.account.add
import de.bixilon.minosoft.Minosoft
import de.bixilon.minosoft.config.profile.profiles.eros.ErosProfileManager
import de.bixilon.minosoft.data.accounts.types.microsoft.MicrosoftAccount
import de.bixilon.minosoft.gui.eros.controller.JavaFXWindowController
import de.bixilon.minosoft.gui.eros.dialog.ErosErrorReport.Companion.report
import de.bixilon.minosoft.gui.eros.main.account.AccountController
import de.bixilon.minosoft.gui.eros.util.JavaFXUtil
import de.bixilon.minosoft.gui.eros.util.JavaFXUtil.ctext
import de.bixilon.minosoft.gui.eros.util.JavaFXUtil.text
import de.bixilon.minosoft.util.KUtil.toResourceLocation
import de.bixilon.minosoft.util.account.microsoft.AuthenticationResponse
import de.bixilon.minosoft.util.account.microsoft.MicrosoftOAuthUtils
import de.bixilon.minosoft.util.account.microsoft.code.MicrosoftDeviceCode
import javafx.fxml.FXML
import javafx.scene.control.Button
import javafx.scene.control.TextField
import javafx.scene.text.TextFlow
import javafx.stage.Modality
import java.net.URL
class MicrosoftAddController(
private val accountController: AccountController,
private val account: MicrosoftAccount? = null,
) : JavaFXWindowController() {
@FXML private lateinit var textFX: TextFlow
private val profile = ErosProfileManager.selected.general.accountProfile
@FXML private lateinit var headerFX: TextFlow
@FXML private lateinit var codeFX: TextField
@FXML private lateinit var cancelFX: Button
fun show() {
fun request() {
MicrosoftOAuthUtils.obtainDeviceCodeAsync(this::codeCallback, this::errorCallback, this::authenticationResponseCallback)
}
private fun errorCallback(exception: Throwable) {
JavaFXUtil.runLater { stage.close() }
exception.report()
}
private fun codeCallback(code: MicrosoftDeviceCode) {
JavaFXUtil.runLater {
JavaFXUtil.openModal(TITLE, LAYOUT, this, modality = Modality.APPLICATION_MODAL)
headerFX.text = HEADER(code.verificationURI)
codeFX.text = code.userCode
stage.show()
}
}
private fun authenticationResponseCallback(response: AuthenticationResponse) {
val account = MicrosoftOAuthUtils.loginToMicrosoftAccount(response)
profile.entries[account.id] = account
if (this.account == null) {
profile.selected = account
}
JavaFXUtil.runLater {
stage.hide()
accountController.refreshList()
}
}
override fun init() {
super.init()
val profile = ErosProfileManager.selected.general.accountProfile
cancelFX.ctext = CANCEL
}
@FXML
fun cancel() {
TODO()
}
companion object {
private val LAYOUT = "minosoft:eros/main/account/add/microsoft.fxml".toResourceLocation()
private val TITLE = "minosoft:main.account.add.microsoft.title".toResourceLocation()
private val HEADER = { link: URL -> Minosoft.LANGUAGE_MANAGER.translate("minosoft:main.account.add.microsoft.header".toResourceLocation(), null, link) }
private val CANCEL = "minosoft:main.account.add.microsoft.cancel".toResourceLocation()
}
}

View File

@ -1,6 +1,6 @@
/*
* Minosoft
* Copyright (C) 2020 Moritz Zwerger
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
@ -66,8 +66,8 @@ public final class ProtocolDefinition {
public static final int ITEM_STACK_MAX_SIZE = 64;
public static final String MICROSOFT_ACCOUNT_APPLICATION_ID = "00000000402b5328"; // ToDo: Should we use our own application id?
// public static final String MICROSOFT_ACCOUNT_APPLICATION_ID = "fe6f0fbf-3038-486a-9c84-6a28b71e0455";
// public static final String MICROSOFT_ACCOUNT_APPLICATION_ID = "00000000402b5328"; // ToDo: Should we use our own application id?
public static final String MICROSOFT_ACCOUNT_APPLICATION_ID = "feb3836f-0333-4185-8eb9-4cbf0498f947"; // Minosoft 2 (microsoft-bixilon2)
public static final String MICROSOFT_ACCOUNT_OAUTH_FLOW_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=" + MICROSOFT_ACCOUNT_APPLICATION_ID + "&scope=XboxLive.signin%20offline_access&response_type=code";
public static final String MICROSOFT_ACCOUNT_AUTH_TOKEN_URL = "https://login.live.com/oauth20_token.srf";
public static final String MICROSOFT_ACCOUNT_XBOX_LIVE_AUTHENTICATE_URL = "https://user.auth.xboxlive.com/user/authenticate";

View File

@ -1,6 +1,6 @@
/*
* Minosoft
* Copyright (C) 2021 Moritz Zwerger
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
@ -13,14 +13,14 @@
package de.bixilon.minosoft.util.account
import de.bixilon.kutil.cast.CastUtil.nullCast
import de.bixilon.kutil.cast.CastUtil.unsafeCast
import de.bixilon.kutil.uuid.UUIDUtil.trim
import de.bixilon.minosoft.data.accounts.MojangAccountInfo
import de.bixilon.minosoft.data.accounts.types.microsoft.MinecraftTokens
import de.bixilon.minosoft.protocol.protocol.ProtocolDefinition
import de.bixilon.minosoft.util.account.microsoft.minecraft.MinecraftAPIException
import de.bixilon.minosoft.util.account.microsoft.minecraft.MinecraftProfile
import de.bixilon.minosoft.util.http.HTTP2.getJson
import de.bixilon.minosoft.util.http.HTTP2.postJson
import de.bixilon.minosoft.util.http.exceptions.AuthenticationException
import de.bixilon.minosoft.util.json.Jackson
import de.bixilon.minosoft.util.logging.Log
import de.bixilon.minosoft.util.logging.LogLevels
import de.bixilon.minosoft.util.logging.LogMessageType
@ -29,24 +29,16 @@ import java.util.*
object AccountUtil {
private const val MOJANG_URL_JOIN = "https://sessionserver.mojang.com/session/minecraft/join"
fun getMojangAccountInfo(bearerToken: String): MojangAccountInfo {
fun fetchMinecraftProfile(token: MinecraftTokens): MinecraftProfile {
val response = ProtocolDefinition.MICROSOFT_ACCOUNT_GET_MOJANG_PROFILE_URL.getJson(mapOf(
"Authorization" to "Bearer $bearerToken"
"Authorization" to "Bearer ${token.accessToken}",
))
response.body!!
if (response.statusCode != 200) {
val errorMessage = when (response.statusCode) {
404 -> "You don't have a copy of minecraft!"
else -> response.body["errorMessage"].unsafeCast()
}
throw LoginException(response.statusCode, "Could not get minecraft profile", errorMessage)
throw MinecraftAPIException(response) // 404 means that the account has not purchased minecraft
}
return MojangAccountInfo(
id = response.body["id"].unsafeCast(),
name = response.body["name"].unsafeCast(),
)
return Jackson.MAPPER.convertValue(response.body, MinecraftProfile::class.java)
}
fun joinMojangServer(username: String, accessToken: String, selectedProfile: UUID, serverId: String) {
@ -57,9 +49,8 @@ object AccountUtil {
).postJson(MOJANG_URL_JOIN)
if (response.statusCode != 204) {
response.body!!
throw AuthenticationException(response.statusCode, response.body["errorMessage"]?.nullCast())
if (response.statusCode != 204 && response.statusCode != 200) {
throw MinecraftAPIException(response)
}
Log.log(LogMessageType.AUTHENTICATION, LogLevels.VERBOSE) { "Mojang server join successful (username=$username, serverId=$serverId)" }

View File

@ -0,0 +1,38 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft
import de.bixilon.kutil.time.TimeUtil
import de.bixilon.minosoft.data.accounts.types.microsoft.MicrosoftTokens
class AuthenticationResponse(
val tokenType: TokenTypes,
scope: String,
expiresIn: Int,
val accessToken: String,
val idToken: String?,
val refreshToken: String,
) {
val expires: Long = (TimeUtil.time / 1000L) + expiresIn
val scope = scope.split(' ')
fun saveTokens(): MicrosoftTokens {
return MicrosoftTokens(accessToken = accessToken, refreshToken = refreshToken, expires = expires)
}
enum class TokenTypes {
BEARER,
;
}
}

View File

@ -0,0 +1,25 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft
import java.util.*
class MicrosoftAPIError(
val traceId: UUID,
val errorDescription: String,
val correlationId: UUID,
val errorCodes: List<Int>,
val error: String,
val timestamp: String,
)

View File

@ -0,0 +1,26 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft
import de.bixilon.kutil.exception.ExceptionUtil
import de.bixilon.minosoft.util.http.HTTPResponse
import de.bixilon.minosoft.util.json.Jackson
class MicrosoftAPIException(
val errorCode: Int,
val error: MicrosoftAPIError?,
) : Exception(error?.errorDescription) {
constructor(response: HTTPResponse<Map<String, Any>?>) : this(response.statusCode, response.body?.let { ExceptionUtil.tryCatch(Throwable::class.java) { Jackson.MAPPER.convertValue(it, MicrosoftAPIError::class.java) } })
}

View File

@ -13,78 +13,148 @@
package de.bixilon.minosoft.util.account.microsoft
import de.bixilon.kutil.cast.CastUtil.unsafeCast
import de.bixilon.kutil.json.JsonUtil.asJsonList
import de.bixilon.kutil.json.JsonUtil.asJsonObject
import de.bixilon.kutil.primitive.LongUtil.toLong
import de.bixilon.kutil.uuid.UUIDUtil.toUUID
import de.bixilon.kutil.concurrent.pool.DefaultThreadPool
import de.bixilon.kutil.concurrent.time.TimeWorker
import de.bixilon.kutil.concurrent.time.TimeWorkerTask
import de.bixilon.kutil.time.TimeUtil
import de.bixilon.minosoft.data.accounts.AccountStates
import de.bixilon.minosoft.data.accounts.types.microsoft.MicrosoftAccount
import de.bixilon.minosoft.data.accounts.types.microsoft.MicrosoftTokens
import de.bixilon.minosoft.data.player.properties.PlayerProperties
import de.bixilon.minosoft.protocol.protocol.ProtocolDefinition
import de.bixilon.minosoft.util.account.AccountUtil
import de.bixilon.minosoft.util.account.LoginException
import de.bixilon.minosoft.util.account.microsoft.code.MicrosoftDeviceCode
import de.bixilon.minosoft.util.account.microsoft.minecraft.MinecraftAPIException
import de.bixilon.minosoft.util.account.microsoft.minecraft.MinecraftBearerResponse
import de.bixilon.minosoft.util.account.microsoft.xbox.XSTSToken
import de.bixilon.minosoft.util.account.microsoft.xbox.XboxAPIError
import de.bixilon.minosoft.util.account.microsoft.xbox.XboxAPIException
import de.bixilon.minosoft.util.account.microsoft.xbox.XboxLiveToken
import de.bixilon.minosoft.util.http.HTTP2.postData
import de.bixilon.minosoft.util.http.HTTP2.postJson
import de.bixilon.minosoft.util.json.Jackson
import de.bixilon.minosoft.util.logging.Log
import de.bixilon.minosoft.util.logging.LogLevels
import de.bixilon.minosoft.util.logging.LogMessageType
import de.bixilon.minosoft.util.url.URLProtocolStreamHandlers
import java.net.URL
import java.net.URLConnection
import java.net.URLStreamHandler
import java.util.concurrent.TimeoutException
object MicrosoftOAuthUtils {
const val TENANT = "consumers"
const val DEVICE_CODE_URL = "https://login.microsoftonline.com/$TENANT/oauth2/v2.0/devicecode"
const val TOKEN_CHECK_URL = "https://login.microsoftonline.com/$TENANT/oauth2/v2.0/token"
const val MAX_CHECK_TIME = 900
fun loginToMicrosoftAccount(authorizationCode: String): MicrosoftAccount {
fun obtainDeviceCodeAsync(
tokenCallback: (MicrosoftDeviceCode) -> Unit,
errorCallback: (Throwable) -> Unit,
successCallback: (AuthenticationResponse) -> Unit,
) {
DefaultThreadPool += {
val deviceCode = obtainDeviceCode()
Log.log(LogMessageType.AUTHENTICATION, LogLevels.INFO) { "Obtained device code: ${deviceCode.userCode}" }
tokenCallback(deviceCode)
val start = TimeUtil.time / 1000
fun checkToken() {
try {
val response = checkDeviceCode(deviceCode)
val time = TimeUtil.time / 1000
if (time > start + MAX_CHECK_TIME || time > deviceCode.expires) {
throw TimeoutException("Could not obtain access for device code: ${deviceCode.userCode}")
}
if (response == null) {
// no response yet
TimeWorker += TimeWorkerTask(deviceCode.interval * 1000, true) { checkToken() }
return
}
Log.log(LogMessageType.AUTHENTICATION, LogLevels.INFO) { "Code (${deviceCode.userCode}) is valid, logging in..." }
successCallback(response)
} catch (exception: Throwable) {
exception.printStackTrace()
errorCallback(exception)
}
}
checkToken()
}
}
fun obtainDeviceCode(): MicrosoftDeviceCode {
val response = mapOf(
"client_id" to ProtocolDefinition.MICROSOFT_ACCOUNT_APPLICATION_ID,
"scope" to "XboxLive.signin offline_access",
).postData(DEVICE_CODE_URL)
if (response.statusCode != 200) {
throw MicrosoftAPIException(response)
}
return Jackson.MAPPER.convertValue(response.body, MicrosoftDeviceCode::class.java)
}
fun checkDeviceCode(deviceCode: MicrosoftDeviceCode): AuthenticationResponse? {
val response = mapOf(
"grant_type" to "urn:ietf:params:oauth:grant-type:device_code",
"client_id" to ProtocolDefinition.MICROSOFT_ACCOUNT_APPLICATION_ID,
"device_code" to deviceCode.deviceCode,
).postData(TOKEN_CHECK_URL)
if (response.statusCode != 200) {
val error = MicrosoftAPIException(response)
if (error.error?.error == "authorization_pending") {
return null
}
throw error
}
return Jackson.MAPPER.convertValue(response.body, AuthenticationResponse::class.java)
}
fun refreshToken(token: MicrosoftTokens): AuthenticationResponse {
val response = mapOf(
"client_id" to ProtocolDefinition.MICROSOFT_ACCOUNT_APPLICATION_ID,
"grant_type" to "refresh_token",
"scope" to "XboxLive.signin offline_access",
"refresh_token" to token.refreshToken,
).postData(TOKEN_CHECK_URL)
if (response.statusCode != 200) {
throw MicrosoftAPIException(response)
}
return Jackson.MAPPER.convertValue(response.body, AuthenticationResponse::class.java)
}
fun loginToMicrosoftAccount(response: AuthenticationResponse): MicrosoftAccount {
Log.log(LogMessageType.AUTHENTICATION, LogLevels.INFO) { "Logging into microsoft account..." }
val authorizationToken = getAuthorizationToken(authorizationCode)
val (xboxLiveToken, userHash) = getXboxLiveToken(authorizationToken)
val msaTokens = response.saveTokens()
val xboxLiveToken = getXboxLiveToken(msaTokens)
val xstsToken = getXSTSToken(xboxLiveToken)
val accessToken = getMinecraftBearerAccessToken(userHash, xstsToken)
val accountInfo = AccountUtil.getMojangAccountInfo(accessToken)
val minecraftToken = getMinecraftBearerAccessToken(xboxLiveToken, xstsToken).saveTokens()
val profile = AccountUtil.fetchMinecraftProfile(minecraftToken)
val playerProperties = PlayerProperties.fetch(profile.uuid)
val uuid = accountInfo.id.toUUID()
val account = MicrosoftAccount(
uuid = uuid,
username = accountInfo.name,
authorizationToken = authorizationToken,
properties = PlayerProperties.fetch(uuid),
uuid = profile.uuid,
username = profile.name,
msa = msaTokens,
minecraft = minecraftToken,
properties = playerProperties,
)
account.state = AccountStates.WORKING
account.accessToken = accessToken
account.check(null, "") // client token does not exist for microsoft accounts
Log.log(LogMessageType.AUTHENTICATION, LogLevels.INFO) { "Microsoft account login successful (uuid=${account.uuid})" }
Log.log(LogMessageType.AUTHENTICATION, LogLevels.INFO) { "Microsoft account login successful (username=${account.username}, uuid=${account.uuid})" }
return account
}
fun getAuthorizationToken(authorizationCode: String): String {
val response = mapOf(
"client_id" to ProtocolDefinition.MICROSOFT_ACCOUNT_APPLICATION_ID,
"code" to authorizationCode,
"grant_type" to "authorization_code",
"scope" to "service::user.auth.xboxlive.com::MBI_SSL",
).postData(ProtocolDefinition.MICROSOFT_ACCOUNT_AUTH_TOKEN_URL)
if (response.statusCode != 200) {
throw LoginException(response.statusCode, "Could not get authorization token", response.body.toString())
}
response.body!!
return response.body["access_token"].unsafeCast()
}
/**
* returns A: XBL Token; B: UHS Token
*/
fun getXboxLiveToken(authorizationToken: String): Pair<String, String> {
fun getXboxLiveToken(msaTokens: MicrosoftTokens): XboxLiveToken {
val response = mapOf(
"Properties" to mapOf(
"AuthMethod" to "RPS",
"SiteName" to "user.auth.xboxlive.com",
"RpsTicket" to authorizationToken
"RpsTicket" to "d=${msaTokens.accessToken}",
),
"RelyingParty" to "http://auth.xboxlive.com",
"TokenType" to "JWT",
@ -92,56 +162,46 @@ object MicrosoftOAuthUtils {
if (response.statusCode != 200 || response.body == null) {
throw LoginException(response.statusCode, "Could not authenticate with xbox live token", response.body.toString())
throw XboxAPIException(response)
}
return Pair(response.body["Token"].unsafeCast(), response.body["DisplayClaims"].asJsonObject()["xui"].asJsonList()[0].asJsonObject()["uhs"].unsafeCast())
return Jackson.MAPPER.convertValue(response.body, XboxLiveToken::class.java)
}
fun getXSTSToken(xBoxLiveToken: String): String {
fun getXSTSToken(xBoxLiveToken: XboxLiveToken): XSTSToken {
val response = mapOf(
"Properties" to mapOf(
"SandboxId" to "RETAIL",
"UserTokens" to listOf(xBoxLiveToken)
"UserTokens" to listOf(xBoxLiveToken.token)
),
"RelyingParty" to "rp://api.minecraftservices.com/",
"TokenType" to "JWT",
).postJson(ProtocolDefinition.MICROSOFT_ACCOUNT_XSTS_URL)
response.body!!
if (response.statusCode != 200) {
val errorMessage = when (response.body["XErr"].toLong()) {
val error = Jackson.MAPPER.convertValue(response.body, XboxAPIError::class.java)
val errorMessage = when (error.error) {
2148916233 -> "You don't have an XBox account!"
2148916235 -> "Xbox Live is banned in your country!"
2148916236, 2148916237 -> "Your account needs adult verification (South Korea)"
2148916238 -> "This account is a child account!"
else -> response.body["Message"].unsafeCast()
else -> error.message ?: "Unknown"
}
throw LoginException(response.statusCode, "Could not get xsts token", errorMessage)
}
return response.body["Token"].unsafeCast()
throw XboxAPIException(response.statusCode, error, errorMessage)
}
fun getMinecraftBearerAccessToken(userHash: String, xstsToken: String): String {
return Jackson.MAPPER.convertValue(response.body, XSTSToken::class.java)
}
fun getMinecraftBearerAccessToken(xBoxLiveToken: XboxLiveToken, xstsToken: XSTSToken): MinecraftBearerResponse {
val response = mapOf(
"identityToken" to "XBL3.0 x=${userHash};${xstsToken}",
"identityToken" to "XBL3.0 x=${xBoxLiveToken.userHash};${xstsToken.token}",
"ensureLegacyEnabled" to true,
).postJson(ProtocolDefinition.MICROSOFT_ACCOUNT_MINECRAFT_LOGIN_WITH_XBOX_URL)
response.body!!
if (response.statusCode != 200) {
throw LoginException(response.statusCode, "Could not get minecraft access token ", (response.body["errorMessage"] ?: response.body["error"] ?: "unknown").unsafeCast())
}
return response.body["access_token"].unsafeCast()
}
init {
URLProtocolStreamHandlers.PROTOCOLS["ms-xal-" + ProtocolDefinition.MICROSOFT_ACCOUNT_APPLICATION_ID] = LoginURLHandler
}
private object LoginURLHandler : URLStreamHandler() {
override fun openConnection(url: URL): URLConnection {
return URLProtocolStreamHandlers.NULL_URL_CONNECTION
throw MinecraftAPIException(response)
}
return Jackson.MAPPER.convertValue(response.body, MinecraftBearerResponse::class.java)
}
}

View File

@ -0,0 +1,34 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft.code
import de.bixilon.kutil.time.TimeUtil
import de.bixilon.minosoft.data.text.URLProtocols
import java.net.URL
data class MicrosoftDeviceCode(
val deviceCode: String,
val userCode: String,
val verificationURI: URL,
val expiresIn: Int,
val interval: Int,
val message: String,
) {
val expires = (TimeUtil.time / 1000) + expiresIn
init {
check(verificationURI.protocol == URLProtocols.HTTPS.protocol) { "Insecure url: $verificationURI" }
check(verificationURI.host == "login.microsoftonline.com" || verificationURI.host == "www.microsoft.com" || verificationURI.host == "microsoft.com") { "Invalid verification host: $verificationURI" }
}
}

View File

@ -0,0 +1,22 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft.minecraft
class MinecraftAPIError(
val path: String,
val errorType: String,
val error: String,
val errorMessage: String,
val developerMessage: String,
)

View File

@ -0,0 +1,26 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft.minecraft
import de.bixilon.kutil.exception.ExceptionUtil
import de.bixilon.minosoft.util.http.HTTPResponse
import de.bixilon.minosoft.util.json.Jackson
class MinecraftAPIException(
val errorCode: Int,
val error: MinecraftAPIError?,
) : Exception(error?.developerMessage) {
constructor(response: HTTPResponse<Map<String, Any>?>) : this(response.statusCode, response.body?.let { ExceptionUtil.tryCatch(Throwable::class.java) { Jackson.MAPPER.convertValue(it, MinecraftAPIError::class.java) } })
}

View File

@ -0,0 +1,34 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft.minecraft
import de.bixilon.kutil.time.TimeUtil
import de.bixilon.minosoft.data.accounts.types.microsoft.MinecraftTokens
import de.bixilon.minosoft.util.account.microsoft.AuthenticationResponse
import java.util.*
data class MinecraftBearerResponse(
val username: UUID,
val roles: List<Any>,
val accessToken: String,
val tokenType: AuthenticationResponse.TokenTypes,
val expiresIn: Int,
) {
val expires = (TimeUtil.time / 1000) + expiresIn
fun saveTokens(): MinecraftTokens {
return MinecraftTokens(accessToken = accessToken, expires = expires)
}
}

View File

@ -0,0 +1,25 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft.minecraft
import de.bixilon.kutil.uuid.UUIDUtil.toUUID
data class MinecraftProfile(
val id: String,
val name: String,
val skins: Any?,
val capes: Any?,
) {
val uuid = id.toUUID()
}

View File

@ -0,0 +1,28 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft.xbox
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import de.bixilon.kutil.json.JsonUtil.asJsonList
import de.bixilon.kutil.json.JsonUtil.asJsonObject
data class XSTSToken(
@JsonProperty("IssueInstant") val issueInstant: String,
@JsonProperty("NotAfter") val notAfter: String,
@JsonProperty("Token") val token: String,
@JsonProperty("DisplayClaims") val displayClaims: Map<String, Any>,
) {
@JsonIgnore val userHash = displayClaims["xui"].asJsonList()[0].asJsonObject()["uhs"]
}

View File

@ -0,0 +1,23 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft.xbox
import com.fasterxml.jackson.annotation.JsonProperty
data class XboxAPIError(
@JsonProperty("Identity") val identity: Int,
@JsonProperty("XErr") val error: Long,
@JsonProperty("Message") val message: String?,
@JsonProperty("Redirect") val redirect: String?,
)

View File

@ -0,0 +1,27 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft.xbox
import de.bixilon.kutil.exception.ExceptionUtil
import de.bixilon.minosoft.util.http.HTTPResponse
import de.bixilon.minosoft.util.json.Jackson
class XboxAPIException(
val errorCode: Int,
val error: XboxAPIError?,
message: String? = error?.error?.toString(),
) : Exception(message) {
constructor(response: HTTPResponse<Map<String, Any>?>) : this(response.statusCode, response.body?.let { ExceptionUtil.tryCatch(Throwable::class.java) { Jackson.MAPPER.convertValue(it, XboxAPIError::class.java) } })
}

View File

@ -0,0 +1,28 @@
/*
* Minosoft
* Copyright (C) 2020-2022 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.util.account.microsoft.xbox
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import de.bixilon.kutil.json.JsonUtil.asJsonList
import de.bixilon.kutil.json.JsonUtil.asJsonObject
data class XboxLiveToken(
@JsonProperty("IssueInstant") val issueInstant: String,
@JsonProperty("NotAfter") val notAfter: String,
@JsonProperty("Token") val token: String,
@JsonProperty("DisplayClaims") val displayClaims: Map<String, Any>,
) {
@JsonIgnore val userHash = displayClaims["xui"].asJsonList()[0].asJsonObject()["uhs"]
}

View File

@ -14,29 +14,46 @@
-->
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.text.Text?>
<?import javafx.scene.text.TextFlow?>
<HBox xmlns:fx="http://javafx.com/fxml/1" fx:id="root" prefHeight="90.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/17"> <!-- fx:controller="de.bixilon.minosoft.gui.eros.main.account.add.MicrosoftAddController"-->
<HBox xmlns:fx="http://javafx.com/fxml/1" fx:id="root" prefHeight="120.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/18"> <!-- fx:controller="de.bixilon.minosoft.gui.eros.main.account.add.MicrosoftAddController"-->
<GridPane HBox.hgrow="ALWAYS">
<columnConstraints>
<ColumnConstraints hgrow="ALWAYS"/>
</columnConstraints>
<rowConstraints>
<RowConstraints vgrow="NEVER"/>
<RowConstraints vgrow="NEVER"/>
<RowConstraints vgrow="ALWAYS"/>
<RowConstraints vgrow="NEVER"/>
</rowConstraints>
<TextFlow fx:id="textFX" prefHeight="200.0" prefWidth="200.0">
<TextFlow fx:id="headerFX" prefHeight="200.0" prefWidth="200.0">
<GridPane.margin>
<Insets bottom="5.0" left="5.0" right="5.0" top="5.0"/>
</GridPane.margin>
<Text text="Please open https://microsoft.com/link and enter the following code to proceed with the login:"/>
</TextFlow>
<TextField fx:id="codeFX" editable="false" promptText="Loading...." GridPane.rowIndex="1">
<TextField fx:id="codeFX" alignment="CENTER" editable="false" promptText="DUMMY-CODE" GridPane.rowIndex="1">
<GridPane.margin>
<Insets bottom="10.0" left="10.0" right="10.0" top="10.0"/>
</GridPane.margin>
</TextField>
<GridPane GridPane.rowIndex="3">
<columnConstraints>
<ColumnConstraints hgrow="ALWAYS"/>
<ColumnConstraints hgrow="NEVER"/>
</columnConstraints>
<rowConstraints>
<RowConstraints vgrow="NEVER"/>
</rowConstraints>
<Button fx:id="cancelFX" onAction="#cancel" disable="true" text="Cancel" GridPane.columnIndex="1">
<GridPane.margin>
<Insets bottom="5.0" left="5.0" right="5.0" top="5.0"/>
</GridPane.margin>
</Button>
</GridPane>
</GridPane>
</HBox>

View File

@ -128,8 +128,9 @@ minosoft:main.account.add.mojang.password.placeholder=********
minosoft:main.account.add.mojang.add_button=Add
minosoft:main.account.add.mojang.cancel_button=Cancel
minosoft:main.account.add.microsoft.title=Add microsoft account
minosoft:main.account.add.microsoft.title=Add microsoft account - Minosoft
minosoft:main.account.add.microsoft.header=Please use a web browser to open the page %1$s and enter the following code in order to proceed with the login
minosoft:main.account.add.microsoft.cancel=Cancel
minosoft:connection.kick.title=Kicked from server
minosoft:connection.kick.header=You got kicked