From 217364ffb8730d297ad227db107e782cad45a360 Mon Sep 17 00:00:00 2001 From: RTAkland Date: Fri, 11 Oct 2024 20:49:32 +0800 Subject: [PATCH] feat: add mclogin command --- src/main/kotlin/cn/rtast/fancybot/Const.kt | 5 +- .../commands/lookup/MinecraftCommand.kt | 127 ++++++++++++++++++ .../fancybot/db/MinecraftAccessTokenTable.kt | 63 +++++++++ .../kotlin/cn/rtast/fancybot/entity/Config.kt | 3 +- .../entity/mc/oauth/DeviceCodeResponse.kt | 19 +++ .../entity/mc/oauth/LoginXboxLivePayload.kt | 28 ++++ .../entity/mc/oauth/LoginXboxLiveResponse.kt | 25 ++++ .../entity/mc/oauth/ObtainXSTSPayload.kt | 26 ++++ .../entity/mc/oauth/ObtainXSTSResponse.kt | 18 +++ .../entity/mc/oauth/RedeemCodeResponse.kt | 15 +++ .../kotlin/cn/rtast/fancybot/util/Database.kt | 2 + .../rtast/fancybot/util/file/ConfigManager.kt | 1 + src/main/resources/config.json | 3 +- 13 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/cn/rtast/fancybot/db/MinecraftAccessTokenTable.kt create mode 100644 src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/DeviceCodeResponse.kt create mode 100644 src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/LoginXboxLivePayload.kt create mode 100644 src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/LoginXboxLiveResponse.kt create mode 100644 src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/ObtainXSTSPayload.kt create mode 100644 src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/ObtainXSTSResponse.kt create mode 100644 src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/RedeemCodeResponse.kt diff --git a/src/main/kotlin/cn/rtast/fancybot/Const.kt b/src/main/kotlin/cn/rtast/fancybot/Const.kt index 1f2d093..86a8d8b 100644 --- a/src/main/kotlin/cn/rtast/fancybot/Const.kt +++ b/src/main/kotlin/cn/rtast/fancybot/Const.kt @@ -80,7 +80,8 @@ val DEFAULT_CONFIG = Config( cloudflareR2BucketName = "fancybot", cloudflareR2PublicUrl = "https://114514.com", apiSpaceKey = "114514", - apiRtastKey = "114514" + apiRtastKey = "114514", + azureAppClientId = "114514" ) val configManager = ConfigManager() @@ -123,5 +124,5 @@ val commands = listOf( ZDJDCommand(), ScanQRCodeCommand(), TTSCommand(), SlimeChunkHelperCommand(), SupportMeCommand(), TheCatCommand(), TheHistoryOfTodayCommand(), MCVersionCommand(), MinecraftWikiCommand(), GaoKaoDaysRemainCommand(), IdiomExplainCommand(), NiuziManagerCommand(), - TodayEatCommand(), TodayDrinkCommand() + TodayEatCommand(), TodayDrinkCommand(), MCLoginCommand() ) \ No newline at end of file diff --git a/src/main/kotlin/cn/rtast/fancybot/commands/lookup/MinecraftCommand.kt b/src/main/kotlin/cn/rtast/fancybot/commands/lookup/MinecraftCommand.kt index d8e1e79..e957f54 100644 --- a/src/main/kotlin/cn/rtast/fancybot/commands/lookup/MinecraftCommand.kt +++ b/src/main/kotlin/cn/rtast/fancybot/commands/lookup/MinecraftCommand.kt @@ -11,12 +11,21 @@ package cn.rtast.fancybot.commands.lookup import cn.rtast.fancybot.annotations.CommandDescription import cn.rtast.fancybot.commands.lookup.WikipediaCommand.Companion.extractPlainTextFromHtml import cn.rtast.fancybot.configManager +import cn.rtast.fancybot.db.deleteAccessTokenById +import cn.rtast.fancybot.db.getAccessTokenById +import cn.rtast.fancybot.db.insertNewAccessToken import cn.rtast.fancybot.entity.mc.DecodedSkin import cn.rtast.fancybot.entity.mc.MCVersion import cn.rtast.fancybot.entity.mc.MCWikiPageResponse import cn.rtast.fancybot.entity.mc.MCWikiSearchResponse import cn.rtast.fancybot.entity.mc.Skin import cn.rtast.fancybot.entity.mc.Username +import cn.rtast.fancybot.entity.mc.oauth.DeviceCodeResponse +import cn.rtast.fancybot.entity.mc.oauth.LoginXboxLivePayload +import cn.rtast.fancybot.entity.mc.oauth.LoginXboxLiveResponse +import cn.rtast.fancybot.entity.mc.oauth.ObtainXSTSPayload +import cn.rtast.fancybot.entity.mc.oauth.ObtainXSTSResponse +import cn.rtast.fancybot.entity.mc.oauth.RedeemCodeResponse import cn.rtast.fancybot.enums.mc.MCVersionType import cn.rtast.fancybot.util.Http import cn.rtast.fancybot.util.Logger @@ -28,15 +37,18 @@ import cn.rtast.fancybot.util.str.convertToHTML import cn.rtast.fancybot.util.str.decodeToString import cn.rtast.fancybot.util.str.encodeToBase64 import cn.rtast.fancybot.util.str.fromJson +import cn.rtast.fancybot.util.str.toJson import cn.rtast.fancybot.util.str.uriEncode import cn.rtast.motdpinger.BedrockPing import cn.rtast.motdpinger.JavaPing import cn.rtast.rob.entity.GroupMessage +import cn.rtast.rob.entity.PrivateMessage import cn.rtast.rob.util.BaseCommand import cn.rtast.rob.util.ob.MessageChain import cn.rtast.rob.util.ob.OneBotListener import cn.rtast.rob.util.ob.asMessageChain import cn.rtast.rob.util.ob.asNode +import okhttp3.FormBody import java.awt.AlphaComposite import java.awt.Color import java.awt.image.BufferedImage @@ -375,4 +387,119 @@ class MCPingCommand : BaseCommand() { } } } +} + +class MCLoginCommand : BaseCommand() { + override val commandNames = listOf("/mclogin") + + private val deviceCodeUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode" + private val redeemCodeUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + private val xboxLiveAuthUrl = "https://user.auth.xboxlive.com/user/authenticate" + private val obtainXSTSUrl = "https://xsts.auth.xboxlive.com/xsts/authorize" + private val clientId = configManager.azureAppClientId + private val waitingList = mutableListOf() + private val deviceCodeMap = mutableMapOf() + + /** + * 获取user code 和device code 和验证url + */ + private fun getDeviceCode(): Triple { + val form = FormBody.Builder() + .add("client_id", clientId) + .add("scope", "XboxLive.signin") + .build() + val response = Http.post(deviceCodeUrl, form) + return Triple(response.userCode, response.deviceCode, response.verificationUri) + } + + /** + * 将这个code兑换成一个access token + */ + private fun redeemCode(deviceCode: String): String { + val form = FormBody.Builder() + .add("client_id", clientId) + .add("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + .add("device_code", deviceCode) + .build() + val response = Http.post(redeemCodeUrl, form) + return response.accessToken + } + + /** + * 登录Xbox + */ + private fun loginXboxLive(accessToken: String): Pair { + val payload = LoginXboxLivePayload(properties = LoginXboxLivePayload.Properties("d=$accessToken")) + val response = Http.post(xboxLiveAuthUrl, payload.toJson()) + val token = response.token + val uhs = response.displayClaims.xui.first().uhs + return token to uhs + } + + /** + * 获取最后的mc access token, 这个access token可以直接用于登录游戏 + */ + private fun obtainXSTSToken(token: String, uhs: String): String { + val payload = ObtainXSTSPayload(properties = ObtainXSTSPayload.Properties(listOf(token))) + val response = Http.post(obtainXSTSUrl, payload.toJson()) + return response.token + } + + override suspend fun executeGroup(listener: OneBotListener, message: GroupMessage, args: List) { + if (args.isEmpty()) { + message.reply("这个命令可以获取你的MC登录Token发送 `/mclogin login`进行操作吧") + return + } + deleteAccessTokenById(message.sender.userId) + val action = args.first() + if (action == "确认" || action == "confirm") { + if (message.sender.userId !in waitingList) { + message.reply("你还没开始登陆呢") + } else { + waitingList.removeIf { it == message.sender.userId } + val deviceCode = deviceCodeMap[message.sender.userId]!! + deviceCodeMap.remove(message.sender.userId) + val accessToken = this.redeemCode(deviceCode) + val (token, uhs) = this.loginXboxLive(accessToken) + val minecraftAccessToken = this.obtainXSTSToken(token, uhs) + insertNewAccessToken(minecraftAccessToken, message.sender.userId) + message.reply("你的账号token已经被保存到了数据库中, 请加机器人好友然后发送`/mclogin`来获取这个Token, Token会在你获取之后从数据库中删除") + } + } else { + if (message.sender.userId !in waitingList) { + waitingList.add(message.sender.userId) + val deviceInfo = getDeviceCode() + val msg = MessageChain.Builder() + .addText("你的用户代码是: ${deviceInfo.first}") + .addNewLine() + .addText("请前往: ${deviceInfo.third}进行验证") + .addNewLine() + .addText("登陆完成后发送`/mclogin confirm` 或 `/mclogin 确认`") + .build() + deviceCodeMap[message.sender.userId] = deviceInfo.second + message.reply(msg) + } else { + waitingList.removeIf { it == message.sender.userId } + deviceCodeMap.remove(message.sender.userId) + message.reply("回复错误已结束本次登录流程") + } + } + } + + override suspend fun executePrivate(listener: OneBotListener, message: PrivateMessage, args: List) { + val token = getAccessTokenById(message.sender.userId) + if (token == null) { + message.reply("你还没登录呢!") + } else { + val msg = MessageChain.Builder() + .addText("你的AccessToken如下: ") + .addNewLine() + .addText(token.token) + .addNewLine() + .addText("这个Token是敏感信息请妥善保管") + .build() + deleteAccessTokenById(message.sender.userId) + message.reply(msg) + } + } } \ No newline at end of file diff --git a/src/main/kotlin/cn/rtast/fancybot/db/MinecraftAccessTokenTable.kt b/src/main/kotlin/cn/rtast/fancybot/db/MinecraftAccessTokenTable.kt new file mode 100644 index 0000000..70fa306 --- /dev/null +++ b/src/main/kotlin/cn/rtast/fancybot/db/MinecraftAccessTokenTable.kt @@ -0,0 +1,63 @@ +/* + * Copyright © 2024 RTAkland + * Author: RTAkland + * Date: 2024/10/11 + */ + + +package cn.rtast.fancybot.db + +import cn.rtast.fancybot.db.MinecraftAccessTokenTable.timestamp +import cn.rtast.fancybot.db.MinecraftAccessTokenTable.token +import cn.rtast.fancybot.db.MinecraftAccessTokenTable.userId +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import cn.rtast.fancybot.util.suspendedTransaction +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.selectAll +import java.time.Instant + +object MinecraftAccessTokenTable : Table("mc_access_token") { + val id = integer("id").autoIncrement() + val token = text("token") + val userId = long("user_id") + val timestamp = long("timestamp") + override val primaryKey = PrimaryKey(id) +} + +data class MCAccessTokenEntity( + val token: String, + val userId: Long, + val timestamp: Long +) + +suspend fun insertNewAccessToken(accessToken: String, id: Long) { + suspendedTransaction { + MinecraftAccessTokenTable.insert { + it[token] = accessToken + it[userId] = id + it[timestamp] = Instant.now().epochSecond + } + } +} + +suspend fun getAccessTokenById(id: Long): MCAccessTokenEntity? { + return suspendedTransaction { + MinecraftAccessTokenTable.selectAll() + .where { userId eq id } + .map { + MCAccessTokenEntity( + it[token], + it[userId], + it[timestamp] + ) + }.singleOrNull() + } +} + +suspend fun deleteAccessTokenById(id: Long) { + suspendedTransaction { + MinecraftAccessTokenTable.deleteWhere { userId eq id } + } +} \ No newline at end of file diff --git a/src/main/kotlin/cn/rtast/fancybot/entity/Config.kt b/src/main/kotlin/cn/rtast/fancybot/entity/Config.kt index 65ac040..d444f6c 100644 --- a/src/main/kotlin/cn/rtast/fancybot/entity/Config.kt +++ b/src/main/kotlin/cn/rtast/fancybot/entity/Config.kt @@ -46,5 +46,6 @@ data class Config( val cloudflareR2BucketName: String, val cloudflareR2PublicUrl: String, val apiSpaceKey: String, - val apiRtastKey: String + val apiRtastKey: String, + val azureAppClientId: String, ) \ No newline at end of file diff --git a/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/DeviceCodeResponse.kt b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/DeviceCodeResponse.kt new file mode 100644 index 0000000..37cc34c --- /dev/null +++ b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/DeviceCodeResponse.kt @@ -0,0 +1,19 @@ +/* + * Copyright © 2024 RTAkland + * Author: RTAkland + * Date: 2024/10/11 + */ + + +package cn.rtast.fancybot.entity.mc.oauth + +import com.google.gson.annotations.SerializedName + +data class DeviceCodeResponse( + @SerializedName("user_code") + val userCode: String, + @SerializedName("device_code") + val deviceCode: String, + @SerializedName("verification_uri") + val verificationUri: String +) \ No newline at end of file diff --git a/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/LoginXboxLivePayload.kt b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/LoginXboxLivePayload.kt new file mode 100644 index 0000000..deb7cce --- /dev/null +++ b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/LoginXboxLivePayload.kt @@ -0,0 +1,28 @@ +/* + * Copyright © 2024 RTAkland + * Author: RTAkland + * Date: 2024/10/11 + */ + + +package cn.rtast.fancybot.entity.mc.oauth + +import com.google.gson.annotations.SerializedName + +data class LoginXboxLivePayload( + @SerializedName("Properties") + val properties: Properties, + @SerializedName("RelyingParty") + val relyingParty: String = "http://auth.xboxlive.com", + @SerializedName("TokenType") + val tokenType: String = "JWT", +) { + data class Properties( + @SerializedName("RpsTicket") + val rpsTicket: String, + @SerializedName("AuthMethod") + val authMethod: String = "RPS", + @SerializedName("SiteName") + val siteName: String = "user.auth.xboxlive.com", + ) +} \ No newline at end of file diff --git a/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/LoginXboxLiveResponse.kt b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/LoginXboxLiveResponse.kt new file mode 100644 index 0000000..82468a9 --- /dev/null +++ b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/LoginXboxLiveResponse.kt @@ -0,0 +1,25 @@ +/* + * Copyright © 2024 RTAkland + * Author: RTAkland + * Date: 2024/10/11 + */ + + +package cn.rtast.fancybot.entity.mc.oauth + +import com.google.gson.annotations.SerializedName + +data class LoginXboxLiveResponse( + @SerializedName("Token") + val token: String, + @SerializedName("DisplayClaims") + val displayClaims: DisplayClaims, +) { + data class DisplayClaims( + val xui: List + ) + + data class XUI( + val uhs: String + ) +} \ No newline at end of file diff --git a/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/ObtainXSTSPayload.kt b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/ObtainXSTSPayload.kt new file mode 100644 index 0000000..5dff00e --- /dev/null +++ b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/ObtainXSTSPayload.kt @@ -0,0 +1,26 @@ +/* + * Copyright © 2024 RTAkland + * Author: RTAkland + * Date: 2024/10/11 + */ + + +package cn.rtast.fancybot.entity.mc.oauth + +import com.google.gson.annotations.SerializedName + +data class ObtainXSTSPayload( + @SerializedName("Properties") + val properties: Properties, + @SerializedName("RelyingParty") + val relyingParty: String = "rp://api.minecraftservices.com/", + @SerializedName("TokenType") + val tokenType: String = "JWT", +) { + data class Properties( + @SerializedName("UserTokens") + val userTokens: List, + @SerializedName("SandboxId") + val sandboxId: String = "RETAIL", + ) +} \ No newline at end of file diff --git a/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/ObtainXSTSResponse.kt b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/ObtainXSTSResponse.kt new file mode 100644 index 0000000..e99ade5 --- /dev/null +++ b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/ObtainXSTSResponse.kt @@ -0,0 +1,18 @@ +/* + * Copyright © 2024 RTAkland + * Author: RTAkland + * Date: 2024/10/11 + */ + + +package cn.rtast.fancybot.entity.mc.oauth + +import cn.rtast.fancybot.entity.mc.oauth.LoginXboxLiveResponse.DisplayClaims +import com.google.gson.annotations.SerializedName + +data class ObtainXSTSResponse( + @SerializedName("Token") + val token: String, + @SerializedName("DisplayClaims") + val displayClaims: DisplayClaims, +) diff --git a/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/RedeemCodeResponse.kt b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/RedeemCodeResponse.kt new file mode 100644 index 0000000..27f2106 --- /dev/null +++ b/src/main/kotlin/cn/rtast/fancybot/entity/mc/oauth/RedeemCodeResponse.kt @@ -0,0 +1,15 @@ +/* + * Copyright © 2024 RTAkland + * Author: RTAkland + * Date: 2024/10/11 + */ + + +package cn.rtast.fancybot.entity.mc.oauth + +import com.google.gson.annotations.SerializedName + +data class RedeemCodeResponse( + @SerializedName("access_token") + val accessToken: String, +) \ No newline at end of file diff --git a/src/main/kotlin/cn/rtast/fancybot/util/Database.kt b/src/main/kotlin/cn/rtast/fancybot/util/Database.kt index b13a0e0..432a9b0 100644 --- a/src/main/kotlin/cn/rtast/fancybot/util/Database.kt +++ b/src/main/kotlin/cn/rtast/fancybot/util/Database.kt @@ -11,6 +11,7 @@ import cn.rtast.fancybot.ROOT_PATH import cn.rtast.fancybot.db.ActionTable import cn.rtast.fancybot.db.BlackListTable import cn.rtast.fancybot.db.JrrpTable +import cn.rtast.fancybot.db.MinecraftAccessTokenTable import cn.rtast.fancybot.db.NiuziBankTable import cn.rtast.fancybot.db.NiuziTable import kotlinx.coroutines.Dispatchers @@ -26,6 +27,7 @@ suspend fun initDatabase() { SchemaUtils.createMissingTablesAndColumns(NiuziTable) SchemaUtils.createMissingTablesAndColumns(ActionTable) SchemaUtils.createMissingTablesAndColumns(BlackListTable) + SchemaUtils.createMissingTablesAndColumns(MinecraftAccessTokenTable) } } diff --git a/src/main/kotlin/cn/rtast/fancybot/util/file/ConfigManager.kt b/src/main/kotlin/cn/rtast/fancybot/util/file/ConfigManager.kt index e973ffd..2d70e22 100644 --- a/src/main/kotlin/cn/rtast/fancybot/util/file/ConfigManager.kt +++ b/src/main/kotlin/cn/rtast/fancybot/util/file/ConfigManager.kt @@ -47,4 +47,5 @@ class ConfigManager : JsonFileHandler("config.json") { val cloudflareR2PublicUrl = config.cloudflareR2PublicUrl val apiSpaceKey = config.apiSpaceKey val apiRtastKey = config.apiRtastKey + val azureAppClientId = config.azureAppClientId } \ No newline at end of file diff --git a/src/main/resources/config.json b/src/main/resources/config.json index 4329dd9..6809cd8 100644 --- a/src/main/resources/config.json +++ b/src/main/resources/config.json @@ -38,5 +38,6 @@ "cloudflareR2BucketName": "fancybot", "cloudflareR2PublicUrl": "https://114514.com", "apiSpaceKey": "114514", - "apiRtastKey": "114514" + "apiRtastKey": "114514", + "azureAppClientId": "114514" } \ No newline at end of file