diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 681df12f..317e5cb8 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -8,8 +8,8 @@ - - + + diff --git a/.idea/misc.xml b/.idea/misc.xml index 799110b5..4b3a5758 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -4,4 +4,5 @@ + \ No newline at end of file diff --git a/.idea/runConfigurations/Start_API.xml b/.idea/runConfigurations/Start_API.xml index e79a8ae0..a59a07c8 100644 --- a/.idea/runConfigurations/Start_API.xml +++ b/.idea/runConfigurations/Start_API.xml @@ -4,7 +4,7 @@ - + diff --git a/api/config.example.json b/api/config.example.json index 84f6c190..846afb1e 100644 --- a/api/config.example.json +++ b/api/config.example.json @@ -21,6 +21,11 @@ "clientId": "", "clientSecret": "", "redirectUri": "" + }, + "microsoft": { + "clientId": "", + "clientSecret": "", + "redirectUri": "" } }, "database": { diff --git a/api/src/main/kotlin/dev/synapsetech/tzdb/config/oauth.kt b/api/src/main/kotlin/dev/synapsetech/tzdb/config/oauth.kt index 1803c8ae..fcee1328 100644 --- a/api/src/main/kotlin/dev/synapsetech/tzdb/config/oauth.kt +++ b/api/src/main/kotlin/dev/synapsetech/tzdb/config/oauth.kt @@ -21,6 +21,7 @@ data class OauthConfigPart( val github: OauthCodeGrantProvider, val twitch: OauthCodeGrantProvider, val twitter: Oauth1aProvider, + val microsoft: OauthCodeGrantProvider, ) diff --git a/api/src/main/kotlin/dev/synapsetech/tzdb/data/User.kt b/api/src/main/kotlin/dev/synapsetech/tzdb/data/User.kt index 73312744..4dfa15b8 100644 --- a/api/src/main/kotlin/dev/synapsetech/tzdb/data/User.kt +++ b/api/src/main/kotlin/dev/synapsetech/tzdb/data/User.kt @@ -19,6 +19,7 @@ data class User( var githubId: Long? = null, var twitterId: Long? = null, var twitchId: Long? = null, + var minecraftUUID: String? = null, var zoneId: String = "UTC", ) { fun save() { @@ -32,7 +33,7 @@ data class User( getCollection().deleteOneById(_id) } - fun toApiJson() = Json(_id, username, discordId, githubId, twitterId, twitchId, ZoneId.of(zoneId).toApiJson()) + fun toApiJson() = Json(_id, username, discordId, githubId, twitterId, twitchId, minecraftUUID, ZoneId.of(zoneId).toApiJson()) @Serializable data class Json( val id: Long, @@ -41,6 +42,7 @@ data class User( val githubId: Long?, val twitterId: Long?, val twitchId: Long?, + val minecraftUUID: String?, val timezoneInfo: ZoneInfoJson, ) @@ -56,6 +58,7 @@ data class User( fun findByGithubId(githubId: Long) = getCollection().findOne(User::githubId eq githubId) fun findByTwitterId(twitterId: Long) = getCollection().findOne(User::twitterId eq twitterId) fun findByTwitchId(twitchId: Long) = getCollection().findOne(User::twitchId eq twitchId) + fun findByMinecraftUUID(minecraftUUID: String) = getCollection().findOne(User::minecraftUUID eq minecraftUUID) } } diff --git a/api/src/main/kotlin/dev/synapsetech/tzdb/plugins/Security.kt b/api/src/main/kotlin/dev/synapsetech/tzdb/plugins/Security.kt index 230dd113..692ced9a 100644 --- a/api/src/main/kotlin/dev/synapsetech/tzdb/plugins/Security.kt +++ b/api/src/main/kotlin/dev/synapsetech/tzdb/plugins/Security.kt @@ -1,9 +1,5 @@ package dev.synapsetech.tzdb.plugins -import io.ktor.server.auth.* -import kotlinx.serialization.Serializable -import io.ktor.http.* -import io.ktor.server.auth.jwt.* import com.auth0.jwt.JWT import com.auth0.jwt.JWTVerifier import com.auth0.jwt.algorithms.Algorithm @@ -13,9 +9,15 @@ import dev.synapsetech.tzdb.httpClient import dev.synapsetech.tzdb.util.TwitterTransport import io.ktor.client.call.* import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* import io.ktor.server.response.* import io.ktor.server.routing.* +import kotlinx.serialization.Serializable +import java.math.BigInteger import java.util.* const val discordAuthorizeUrl = "https://discord.com/api/oauth2/authorize" @@ -27,6 +29,9 @@ const val githubTokenUrl = "https://github.com/login/oauth/access_token" const val twitchAuthorizeUrl = "https://id.twitch.tv/oauth2/authorize" const val twitchTokenUrl = "https://id.twitch.tv/oauth2/token" +const val microsoftAuthorizeUrl = "https://login.live.com/oauth20_authorize.srf" +const val microsoftTokenUrl = "https://login.live.com/oauth20_token.srf" + fun genJwt(userId: Long): String = JWT.create() .withAudience(MainConfig.instance.jwt.audience) .withIssuer(MainConfig.instance.jwt.domain) @@ -105,6 +110,22 @@ fun Application.configureSecurity() { client = httpClient } + oauth("auth-oauth-microsoft") { + urlProvider = { MainConfig.instance.oauth.microsoft.redirectUri } + providerLookup = { + OAuthServerSettings.OAuth2ServerSettings( + name = "microsoft", + authorizeUrl = microsoftAuthorizeUrl, + accessTokenUrl = microsoftTokenUrl, + requestMethod = HttpMethod.Post, + clientId = MainConfig.instance.oauth.microsoft.clientId, + clientSecret = MainConfig.instance.oauth.microsoft.clientSecret, + defaultScopes = listOf("XboxLive.signin", "Xboxlive.offline_access", "User.Read") + ) + } + client = httpClient + } + jwt("auth-jwt") { val jwtAudience = MainConfig.instance.jwt.audience realm = MainConfig.instance.jwt.realm @@ -165,6 +186,7 @@ fun Application.configureSecurity() { val response = httpClient.get("https://discord.com/api/users/@me") { headers { append("Authorization", "Bearer ${principal?.accessToken}") + append("User-Agent", "TimezoneDB Authentication Agent/1.0 (+https://tzdb.synapsetech.dev)") } } @@ -255,6 +277,7 @@ fun Application.configureSecurity() { headers { append("Accept", "application/vnd.github.v3+json") append("Authorization", "token ${principal?.accessToken}") + append("User-Agent", "TimezoneDB Authentication Agent/1.0 (+https://tzdb.synapsetech.dev)") } } @@ -266,6 +289,7 @@ fun Application.configureSecurity() { headers { append("Accept", "application/vnd.github.v3+json") append("Authorization", "token ${principal?.accessToken}") + append("User-Agent", "TimezoneDB Authentication Agent/1.0 (+https://tzdb.synapsetech.dev)") } } @@ -439,6 +463,7 @@ fun Application.configureSecurity() { append("Accept", "application/json") append("Authorization", "Bearer ${principal?.accessToken}") append("Client-Id", MainConfig.instance.oauth.twitch.clientId) + append("User-Agent", "TimezoneDB Authentication Agent/1.0 (+https://tzdb.synapsetech.dev)") } } @@ -481,6 +506,133 @@ fun Application.configureSecurity() { call.respondRedirect(webUri.toString()) } } + + get("/auth/microsoft") { + call.request.queryParameters["intent"]?.let { intent -> + if (intent == "link") { + val jwt = call.request.queryParameters["token"] ?: run { + call.respond(HttpStatusCode.BadRequest) + return@get + } + + val decoded = jwtVerifier.verify(jwt) + val userId = decoded.getClaim("userId").asLong() ?: run { + call.respond(HttpStatusCode.Unauthorized) + return@get + } + + call.response.cookies.append("tzdb-link-user", userId.toString()) + } + } + + call.respondRedirect("/auth/login/microsoft") + } + + authenticate("auth-oauth-microsoft") { + get("/auth/login/microsoft") { + call.respondRedirect("/auth/callback/microsoft") + } + + get("/auth/callback/microsoft") { + val principal: OAuthAccessTokenResponse.OAuth2? = call.principal() + + var link = false + var userId: Long? = null + + call.request.cookies["tzdb-link-user"]?.let { + link = true + userId = it.toLongOrNull() + } + + call.response.cookies.appendExpired("tzdb-link-user") + + if (link && userId == null) { + call.respond(HttpStatusCode.BadRequest) + return@get + } + + val xboxTokenResponse = httpClient.post("https://user.auth.xboxlive.com:443/user/authenticate") { + headers { + append("x-xbl-contract-version", "1") + } + userAgent("TimezoneDB Authentication Agent/1.0 (+https://tzdb.synapsetech.dev)") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(XboxTokenRequest( + XboxTokenRequest.Companion.PropertiesData( + "RPS", + "user.auth.xboxlive.com", + "d=${principal!!.accessToken}" + ), + "http://auth.xboxlive.com", + "JWT" + )) + } + + val xboxTokenResponseData: XboxTokenResponse = xboxTokenResponse.body() + val xboxToken = xboxTokenResponseData.Token + val xboxUhs = xboxTokenResponseData.DisplayClaims.xui[0].uhs + + val xstsTokenResponse = httpClient.post("https://xsts.auth.xboxlive.com:443/xsts/authorize") { + headers { + append("x-xbl-contract-version", "1") + } + userAgent("TimezoneDB Authentication Agent/1.0 (+https://tzdb.synapsetech.dev)") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(XstsTokenRequest( + XstsTokenRequest.Companion.PropertiesData("RETAIL", listOf(xboxToken)), + "rp://api.minecraftservices.com/", + "JWT" + )) + } + val xstsTokenResponseData: XboxTokenResponse = xstsTokenResponse.body() + val xstsToken = xstsTokenResponseData.Token + + val minecraftTokenResponse = httpClient.post("https://api.minecraftservices.com:443/authentication/login_with_xbox") { + userAgent("TimezoneDB Authentication Agent/1.0 (+https://tzdb.synapsetech.dev)") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(MinecraftTokenRequest( + "XBL3.0 x=${xboxUhs};${xstsToken}" + )) + } + + val minecraftTokenResponseData: MinecraftTokenResponse = minecraftTokenResponse.body() + + val minecraftProfileResponse = httpClient.get("https://api.minecraftservices.com:443/minecraft/profile") { + headers { + append("Authorization", "Bearer ${minecraftTokenResponseData.access_token}") + } + userAgent("TimezoneDB Authentication Agent/1.0 (+https://tzdb.synapsetech.dev)") + accept(ContentType.Application.Json) + } + + val minecraftProfile: MinecraftProfile = minecraftProfileResponse.body() + + val bi1 = BigInteger(minecraftProfile.id.substring(0, 16), 16) + val bi2 = BigInteger(minecraftProfile.id.substring(16, 32), 16) + val minecraftUUID = UUID(bi1.toLong(), bi2.toLong()).toString() + + val possibleOtherUser = User.findByMinecraftUUID(minecraftUUID) + if (link) { + if (possibleOtherUser != null && possibleOtherUser._id != userId) { + call.respond(HttpStatusCode.BadRequest, "Account already linked") + return@get + } + + val thisUser = User.findById(userId!!)!! + thisUser.minecraftUUID = minecraftUUID + thisUser.save() + } else { + call.respond(HttpStatusCode.BadRequest, "Cannot use Minecraft for login.") + } +// + val token = genJwt(userId!!) + val webUri = Url(MainConfig.instance.webUrl).toURI().resolve("/?token=$token") + call.respondRedirect(webUri.toString()) + } + } } } @@ -511,3 +663,62 @@ fun Application.configureSecurity() { @Serializable data class TwitchResponse( val data: List, ) + +@Serializable data class XboxTokenRequest( + val Properties: PropertiesData, + val RelyingParty: String, + val TokenType: String, +) { + companion object { + @Serializable data class PropertiesData( + val AuthMethod: String, + val SiteName: String, + val RpsTicket: String, // Microsoft naming at its finest. This is a JWT. + ) + } +} + +@Serializable data class XboxTokenResponse( + val IssueInstant: String, + val NotAfter: String, + val Token: String, + val DisplayClaims: DisplayClaimsData, +) { + companion object { + @Serializable data class DisplayClaimsData( + val xui: List, + ) + + @Serializable data class UserHashData( + val uhs: String, + ) + } +} + +@Serializable data class XstsTokenRequest( + val Properties: PropertiesData, + val RelyingParty: String, + val TokenType: String +) { + companion object { + @Serializable data class PropertiesData( + val SandboxId: String, + val UserTokens: List, + ) + } +} + +@Serializable data class MinecraftTokenRequest( + val identityToken: String, + val ensureLegacyEnabled: Boolean = true, +) + +@Serializable data class MinecraftTokenResponse( + val username: String, + val access_token: String, +) + +@Serializable data class MinecraftProfile( + val name: String, + val id: String, +) \ No newline at end of file diff --git a/api/src/main/kotlin/dev/synapsetech/tzdb/routes/user.kt b/api/src/main/kotlin/dev/synapsetech/tzdb/routes/user.kt index a52d1d63..0c3f16aa 100644 --- a/api/src/main/kotlin/dev/synapsetech/tzdb/routes/user.kt +++ b/api/src/main/kotlin/dev/synapsetech/tzdb/routes/user.kt @@ -100,6 +100,17 @@ fun Route.userRoutes() { if (user != null) call.respond(user.toApiJson()) else call.respond(HttpStatusCode.NotFound) } + + get("/minecraft/{uuid}") { + val minecraftUUID = call.parameters["uuid"] ?: run { + call.respond(HttpStatusCode.BadRequest) + return@get + } + + val user = User.findByMinecraftUUID(minecraftUUID) + if (user != null) call.respond(user.toApiJson()) + else call.respond(HttpStatusCode.NotFound) + } } } } diff --git a/web/src/lib/data.ts b/web/src/lib/data.ts index f0875d9d..badbd64c 100644 --- a/web/src/lib/data.ts +++ b/web/src/lib/data.ts @@ -10,5 +10,6 @@ export interface User { discordId?: number; twitterId?: number; twitchId?: number; + minecraftUUID?: number; timezoneInfo: ZoneInfo; } diff --git a/web/src/styles/button.scss b/web/src/styles/button.scss index e7c063bd..79ca105a 100644 --- a/web/src/styles/button.scss +++ b/web/src/styles/button.scss @@ -130,4 +130,20 @@ } } } + + &.xbox { + background-color: $xbox; + + .linkTag { + color: $xbox; + } + + &:hover:not(.disabled) { + background-color: darken($xbox, 10); + + .linkTag { + color: darken($xbox, 10); + } + } + } } diff --git a/web/src/styles/colors.scss b/web/src/styles/colors.scss index a0cda8ef..52599d19 100644 --- a/web/src/styles/colors.scss +++ b/web/src/styles/colors.scss @@ -3,3 +3,4 @@ $twitter: #1da1f2; $github: #333333; $reddit: #ff4500; $twitch: #9146ff; +$xbox: #52b043; diff --git a/web/src/views/AccountView.vue b/web/src/views/AccountView.vue index ec351510..8f8078d7 100644 --- a/web/src/views/AccountView.vue +++ b/web/src/views/AccountView.vue @@ -1,72 +1,73 @@ @@ -131,6 +132,19 @@ function confirmDeleteAccount() { account.twitchId ? 'Linked' : 'Not Linked' }} + + + Xbox/Microsoft + {{ + account.minecraftUUID ? 'Linked' : 'Not Linked' + }} + @@ -172,49 +186,49 @@ function confirmDeleteAccount() {