diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2430d85 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 + +[*.json] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f811f6a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Disable autocrlf on generated files, they always generate with LF +# Add any extra files or paths here to make git stop saying they +# are changed when only line endings change. +src/generated/**/.cache/cache text eol=lf +src/generated/**/*.json text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81c2061 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# eclipse +bin +*.launch +.settings +.metadata +.classpath +.project + +# idea +/.idea +/out +*.ipr +*.iws +*.iml + +# gradle +/build +/.gradle +/gradle +/gradlew +/gradlew.bat + +# other +eclipse +run +annotations +logs diff --git a/build.gradle b/build.gradle index e80d504..65be9d7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,61 +1,78 @@ plugins { - id 'fabric-loom' version '0.4-SNAPSHOT' - id 'maven-publish' + id 'fabric-loom' version '1.0-SNAPSHOT' } -sourceCompatibility = JavaVersion.VERSION_1_8 -targetCompatibility = JavaVersion.VERSION_1_8 +sourceCompatibility = JavaVersion.VERSION_17 +targetCompatibility = JavaVersion.VERSION_17 archivesBaseName = project.archives_base_name version = project.mod_version +group = project.maven_group + +repositories { + // Add repositories to retrieve artifacts from in here. + // You should only use this when depending on other mods because + // Loom adds the essential maven repositories to download Minecraft and libraries from automatically. + // See https://docs.gradle.org/current/userguide/declaring_repositories.html + // for more information about repositories. + maven { + url "https://maven.terraformersmc.com/releases" + } + maven { + url "https://maven.shedaniel.me/" + } +} dependencies { - //to change the versions see the gradle.properties file - minecraft "com.mojang:minecraft:${project.minecraft_version}" - mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" - modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + // To change the versions see the gradle.properties file + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" - // Fabric API. This is technically optional, but you probably want it anyway. - modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" + // Fabric API. This is technically optional, but you probably want it anyway. + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" - modApi "me.sargunvohra.mcmods:autoconfig1u:3.2.0-unstable" - modImplementation "io.github.prospector:modmenu:1.14.6+build.31" - modApi ("me.shedaniel.cloth:config-2:4.7.0-unstable") { - exclude(group: "net.fabricmc.fabric-api") - } - // PSA: Some older mods, compiled on Loom 0.2.1, might have outdated Maven POMs. - // You may need to force-disable transitiveness on them. + modImplementation "com.terraformersmc:modmenu:${project.modmenu_version}" + // PSA: Some older mods, compiled on Loom 0.2.1, might have outdated Maven POMs. + // You may need to force-disable transitiveness on them. + modApi("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { + exclude(group: "net.fabricmc.fabric-api") + } + implementation "com.electronwill.night-config:toml:${project.night_config_version}" + include "com.electronwill.night-config:core:${project.night_config_version}" + include "com.electronwill.night-config:toml:${project.night_config_version}" } -processResources { - inputs.property "version", project.version +sourceSets { + main { + java.srcDirs += "src/common/java" + resources.srcDirs += "src/common/resources" + } +} - from(sourceSets.main.resources.srcDirs) { - include "fabric.mod.json" - expand "version": project.version - } +processResources { + inputs.property "version", project.version - from(sourceSets.main.resources.srcDirs) { - exclude "fabric.mod.json" - } + filesMatching("fabric.mod.json") { + expand "version": project.version + } } -// ensure that the encoding is set to UTF-8, no matter what the system default is -// this fixes some edge cases with special characters not displaying correctly -// see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html -tasks.withType(JavaCompile) { - options.encoding = "UTF-8" +tasks.withType(JavaCompile).configureEach { + // Minecraft 1.18 (1.18-pre2) upwards uses Java 17. + it.options.release = 17 } // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task // if it is present. // If you remove this task, sources will not be generated. task sourcesJar(type: Jar, dependsOn: classes) { - classifier = "sources" - from sourceSets.main.allSource + classifier = "sources" + from sourceSets.main.allSource } -ext { - autoSignProfile = "reauth" - autoSignTarget = tasks.remapJar.archivePath +jar { + from("LICENSE") { + rename { "${it}_${project.archivesBaseName}" } + } } diff --git a/gradle.properties b/gradle.properties index b51e4f3..ede995d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,16 +1,20 @@ # Done to increase the memory available to gradle. org.gradle.jvmargs=-Xmx1G - # Fabric Properties - # check these on https://fabricmc.net/use - minecraft_version=1.16.2 - yarn_mappings=1.16.2+build.1 - loader_version=0.9.1+build.205 - +# check these on https://fabricmc.net/use +minecraft_version=1.19.2 +# https://maven.fabricmc.net/net/fabricmc/yarn/ +yarn_mappings=1.19.2+build.9 +# https://maven.fabricmc.net/net/fabricmc/fabric-loader/ +loader_version=0.14.9 # Mod Properties - mod_version = 3.9.3 - archives_base_name = ReAuth-1.16-Fabric - +mod_version=4.1.0-Fabric +maven_group=technicianlp.reauth +archives_base_name=ReAuth-1.19.2-Fabric # Dependencies - # currently not on the main fabric site, check on the maven: https://maven.fabricmc.net/net/fabricmc/fabric-api/fabric-api - fabric_version=0.19.0+build.398-1.16 +# currently not on the main fabric site, check on the maven: +# https://maven.fabricmc.net/net/fabricmc/fabric-api/fabric-api +fabric_version=0.61.0+1.19.2 +cloth_config_version=8.2.88 +modmenu_version=4.0.6 +night_config_version=3.6.6 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7454180 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b1159fc --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..c53aefa --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle index a9ae1f4..f47b416 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,12 +1,12 @@ pluginManagement { repositories { - jcenter() maven { name = 'Fabric' url = 'https://maven.fabricmc.net/' } + mavenCentral() gradlePluginPortal() } } -rootProject.name='ReAuth Fabric 1.16' +rootProject.name = 'ReAuth Fabric 1.19.2' diff --git a/src/common/java/technicianlp/reauth/authentication/MsAuthAPI.java b/src/common/java/technicianlp/reauth/authentication/MsAuthAPI.java new file mode 100644 index 0000000..4e57eb9 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/MsAuthAPI.java @@ -0,0 +1,102 @@ +package technicianlp.reauth.authentication; + +import technicianlp.reauth.authentication.dto.microsoft.MicrosoftAuthRefreshRequest; +import technicianlp.reauth.authentication.dto.microsoft.MicrosoftAuthResponse; +import technicianlp.reauth.authentication.dto.microsoft.code.MicrosoftAuthCodeRequest; +import technicianlp.reauth.authentication.dto.microsoft.device.MicrosoftAuthDeviceRequest; +import technicianlp.reauth.authentication.dto.microsoft.device.MicrosoftAuthDeviceResponse; +import technicianlp.reauth.authentication.dto.microsoft.device.MicrosoftAuthDeviceTokenRequest; +import technicianlp.reauth.authentication.dto.mojang.MojangAuthRequest; +import technicianlp.reauth.authentication.dto.mojang.MojangAuthResponse; +import technicianlp.reauth.authentication.dto.mojang.ProfileResponse; +import technicianlp.reauth.authentication.dto.xbox.XboxAuthResponse; +import technicianlp.reauth.authentication.dto.xbox.XboxLiveAuthRequest; +import technicianlp.reauth.authentication.dto.xbox.XboxXstsAuthRequest; +import technicianlp.reauth.authentication.http.HttpUtil; +import technicianlp.reauth.authentication.http.InvalidResponseException; +import technicianlp.reauth.authentication.http.Response; +import technicianlp.reauth.authentication.http.UnreachableServiceException; + + +public enum MsAuthAPI { + ; + + public static final String clientId = "fa861065-c46c-4ac9-a4da-59a7d40b8a72"; + + public static final int port = 52371; + public static final String redirectUri = "http://127.0.0.1:" + port; + @SuppressWarnings("StringConcatenationMissingWhitespace") + private static final String redirectUriEncoded = "http%3A%2F%2F127%2E0%2E0%2E1%3A" + port; + + private static final String scopeBasic = "XboxLive.signin"; + private static final String scopePersist = "XboxLive.signin XboxLive.offline_access"; + private static final String scopePersistUrl = "XboxLive.signin+XboxLive.offline_access"; + + private static final String urlMicrosoftAuthorize = + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; + private static final String urlMicrosoftToken = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; + private static final String urlMicrosoftDevice = + "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; + private static final String urlXboxLive = "https://user.auth.xboxlive.com/user/authenticate"; + private static final String urlXsts = "https://xsts.auth.xboxlive.com/xsts/authorize"; + private static final String urlMojangLogin = "https://api.minecraftservices.com/authentication/login_with_xbox"; + private static final String urlMojangProfile = "https://api.minecraftservices.com/minecraft/profile"; + + public static String getLoginUrl(boolean persist, String pkceChallenge) { + return urlMicrosoftAuthorize + + "?client_id=" + clientId + + "&redirect_uri=" + redirectUriEncoded + + "&scope=" + (persist ? scopePersistUrl : scopeBasic) + + "&response_type=code" + + "&response_mode=form_post" + + "&prompt=select_account" + + "&code_challenge=" + pkceChallenge + + "&code_challenge_method=S256"; + } + + public static MicrosoftAuthResponse redeemAuthorizationCode(String code, String pkceVerifier) throws + UnreachableServiceException, InvalidResponseException { + MicrosoftAuthCodeRequest request = new MicrosoftAuthCodeRequest(code, pkceVerifier); + return HttpUtil.performFormRequest(urlMicrosoftToken, request); + } + + public static MicrosoftAuthDeviceResponse requestDeviceCode(boolean persist) throws UnreachableServiceException, + InvalidResponseException { + MicrosoftAuthDeviceRequest request = new MicrosoftAuthDeviceRequest(persist ? scopePersist : scopeBasic); + return HttpUtil.performFormRequest(urlMicrosoftDevice, request); + } + + public static Response redeemDeviceCode(String deviceCode) throws + UnreachableServiceException { + MicrosoftAuthDeviceTokenRequest request = new MicrosoftAuthDeviceTokenRequest(deviceCode); + return HttpUtil.performWrappedFormRequest(urlMicrosoftToken, request); + } + + public static MicrosoftAuthResponse redeemRefreshToken(String refreshToken) throws UnreachableServiceException, + InvalidResponseException { + MicrosoftAuthRefreshRequest request = new MicrosoftAuthRefreshRequest(refreshToken); + return HttpUtil.performFormRequest(urlMicrosoftToken, request); + } + + public static XboxAuthResponse authenticateXASU(String token) throws UnreachableServiceException, + InvalidResponseException { + XboxLiveAuthRequest request = new XboxLiveAuthRequest(token); + return HttpUtil.performJsonRequest(urlXboxLive, request); + } + + public static Response authenticateXSTS(String xblToken) throws UnreachableServiceException { + XboxXstsAuthRequest request = new XboxXstsAuthRequest(xblToken); + return HttpUtil.performWrappedJsonRequest(urlXsts, request); + } + + public static MojangAuthResponse authenticateMojang(String xstsToken, String uhs) throws + UnreachableServiceException, InvalidResponseException { + MojangAuthRequest request = new MojangAuthRequest(xstsToken, uhs); + return HttpUtil.performJsonRequest(urlMojangLogin, request); + } + + public static ProfileResponse fetchProfile(String accessToken) throws UnreachableServiceException, + InvalidResponseException { + return HttpUtil.performGetRequest(urlMojangProfile, accessToken, ProfileResponse.class); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/SessionData.java b/src/common/java/technicianlp/reauth/authentication/SessionData.java new file mode 100644 index 0000000..f70f8bf --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/SessionData.java @@ -0,0 +1,4 @@ +package technicianlp.reauth.authentication; + +public record SessionData(String username, String uuid, String accessToken, String type) { +} diff --git a/src/common/java/technicianlp/reauth/authentication/YggdrasilAPI.java b/src/common/java/technicianlp/reauth/authentication/YggdrasilAPI.java new file mode 100644 index 0000000..02635b0 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/YggdrasilAPI.java @@ -0,0 +1,50 @@ +package technicianlp.reauth.authentication; + +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService; +import com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication; +import technicianlp.reauth.authentication.dto.yggdrasil.AuthenticateRequest; +import technicianlp.reauth.authentication.dto.yggdrasil.AuthenticateResponse; +import technicianlp.reauth.authentication.dto.yggdrasil.JoinServerRequest; +import technicianlp.reauth.authentication.dto.yggdrasil.JoinServerResponse; +import technicianlp.reauth.authentication.http.HttpUtil; +import technicianlp.reauth.authentication.http.InvalidResponseException; +import technicianlp.reauth.authentication.http.Response; +import technicianlp.reauth.authentication.http.UnreachableServiceException; +import technicianlp.reauth.crypto.Crypto; + +import java.math.BigInteger; +import java.util.UUID; + +/** + * cut down reimplementation version of {@link YggdrasilUserAuthentication} + */ +public enum YggdrasilAPI { + ; + + private static final String urlAuthenticate = "https://authserver.mojang.com/authenticate"; + private static final String urlJoin = "https://sessionserver.mojang.com/session/minecraft/join"; + + /** + * reimplementation of {@link YggdrasilUserAuthentication#logInWithPassword()} + */ + public static SessionData login(String username, String password) throws UnreachableServiceException, + InvalidResponseException { + AuthenticateRequest request = new AuthenticateRequest(username, password, UUID.randomUUID().toString()); + AuthenticateResponse response = HttpUtil.performJsonRequest(urlAuthenticate, request); + return response != null ? response.getSession() : null; + } + + /** + * checks validity of accessToken by invoking the joinServer endpoint + *

+ * reimplementation of {@link YggdrasilMinecraftSessionService#joinServer(GameProfile, String, String)} Server hash + * is generated like during standard login sequence + */ + public static boolean validate(String accessToken, String uuid) throws UnreachableServiceException { + String hash = new BigInteger(Crypto.randomBytes(20)).toString(16); + JoinServerRequest request = new JoinServerRequest(accessToken, uuid, hash); + Response response = HttpUtil.performWrappedJsonRequest(urlJoin, request); + return response.isValid(); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/RequestObject.java b/src/common/java/technicianlp/reauth/authentication/dto/RequestObject.java new file mode 100644 index 0000000..623ebb0 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/RequestObject.java @@ -0,0 +1,25 @@ +package technicianlp.reauth.authentication.dto; + +import java.util.Map; + +/** + * Base Interface for request payloads + */ +public interface RequestObject { + + Class getResponseClass(); + + /** + * Interface for form request payloads + */ + interface Form extends RequestObject { + + Map getFields(); + } + + /** + * Interface for json request payloads + */ + interface JSON extends RequestObject { + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/ResponseObject.java b/src/common/java/technicianlp/reauth/authentication/dto/ResponseObject.java new file mode 100644 index 0000000..482cc7a --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/ResponseObject.java @@ -0,0 +1,17 @@ +package technicianlp.reauth.authentication.dto; + +/** + * Interface for response payloads + */ +public interface ResponseObject { + + /** + * checks whether the request was successful and all required fields have been sent + */ + boolean isValid(); + + /** + * returns the errormessage returned by the service for a failed request + */ + String getError(); +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/microsoft/MicrosoftAuthRefreshRequest.java b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/MicrosoftAuthRefreshRequest.java new file mode 100644 index 0000000..ddcafa0 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/MicrosoftAuthRefreshRequest.java @@ -0,0 +1,44 @@ +package technicianlp.reauth.authentication.dto.microsoft; + +import com.google.common.collect.ImmutableMap; +import technicianlp.reauth.authentication.MsAuthAPI; +import technicianlp.reauth.authentication.dto.RequestObject; + +import java.util.HashMap; +import java.util.Map; + +/** + * Request Payload for the /token Endpoint of the Microsoft Identity Platform Payload is used for refreshing the oauth + * tokens. + * + * @see Microsoft Authentication Scheme on wiki.vg + * @see Microsoft Auth + * Code Flow + * @see Microsoft Device + * Code Flow + */ +public final class MicrosoftAuthRefreshRequest implements RequestObject.Form { + + private final Map fields; + + public MicrosoftAuthRefreshRequest(String refreshToken) { + Map map = new HashMap<>(); + + map.put("client_id", MsAuthAPI.clientId); + map.put("redirect_uri", MsAuthAPI.redirectUri); + map.put("grant_type", "refresh_token"); + map.put("refresh_token", refreshToken); + + this.fields = ImmutableMap.copyOf(map); + } + + @Override + public Class getResponseClass() { + return MicrosoftAuthResponse.class; + } + + @Override + public Map getFields() { + return this.fields; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/microsoft/MicrosoftAuthResponse.java b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/MicrosoftAuthResponse.java new file mode 100644 index 0000000..41f72d0 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/MicrosoftAuthResponse.java @@ -0,0 +1,56 @@ +package technicianlp.reauth.authentication.dto.microsoft; + +import com.google.gson.annotations.SerializedName; +import technicianlp.reauth.authentication.dto.ResponseObject; + +/** + * Response Payload for the /token Endpoint of the Microsoft Identity Platform + *
+ * Only relevant fields are deserialized + * + * @see Microsoft Authentication Scheme on wiki.vg + * @see Microsoft Auth + * Code Flow + * @see Microsoft Device + * Code Flow + */ +public final class MicrosoftAuthResponse implements ResponseObject { + + // Normal Handling + @SerializedName("expires_in") + public final String expires_in; + @SerializedName("access_token") + public final String accessToken; + @SerializedName("refresh_token") + public final String refreshToken; + + // Error Handling + @SerializedName("error") + public final String error; + + private MicrosoftAuthResponse() { + this.expires_in = null; + this.accessToken = null; + this.refreshToken = null; + + this.error = null; + } + + @Override + public boolean isValid() { + return this.error == null && this.expires_in != null && this.accessToken != null; + } + + @Override + public String getError() { + return this.error; + } + + public String getAccessToken() { + return this.accessToken; + } + + public String getRefreshToken() { + return this.refreshToken; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/microsoft/code/MicrosoftAuthCodeRequest.java b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/code/MicrosoftAuthCodeRequest.java new file mode 100644 index 0000000..db2a6fe --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/code/MicrosoftAuthCodeRequest.java @@ -0,0 +1,44 @@ +package technicianlp.reauth.authentication.dto.microsoft.code; + +import com.google.common.collect.ImmutableMap; +import technicianlp.reauth.authentication.MsAuthAPI; +import technicianlp.reauth.authentication.dto.RequestObject; +import technicianlp.reauth.authentication.dto.microsoft.MicrosoftAuthResponse; + +import java.util.HashMap; +import java.util.Map; + +/** + * Request Payload for the /token Endpoint of the Microsoft Identity Platform. Payload is used for redeeming the code + * received in the auth code grant flow. + * + * @see Microsoft Authentication Scheme on wiki.vg + * @see Microsoft Auth + * Code Flow + */ +public final class MicrosoftAuthCodeRequest implements RequestObject.Form { + + private final Map fields; + + public MicrosoftAuthCodeRequest(String authCode, String pkceVerifier) { + Map map = new HashMap<>(); + + map.put("client_id", MsAuthAPI.clientId); + map.put("redirect_uri", MsAuthAPI.redirectUri); + map.put("grant_type", "authorization_code"); + map.put("code", authCode); + map.put("code_verifier", pkceVerifier); + + this.fields = ImmutableMap.copyOf(map); + } + + @Override + public Class getResponseClass() { + return MicrosoftAuthResponse.class; + } + + @Override + public Map getFields() { + return this.fields; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/microsoft/device/MicrosoftAuthDeviceRequest.java b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/device/MicrosoftAuthDeviceRequest.java new file mode 100644 index 0000000..d894dff --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/device/MicrosoftAuthDeviceRequest.java @@ -0,0 +1,40 @@ +package technicianlp.reauth.authentication.dto.microsoft.device; + +import com.google.common.collect.ImmutableMap; +import technicianlp.reauth.authentication.MsAuthAPI; +import technicianlp.reauth.authentication.dto.RequestObject; + +import java.util.HashMap; +import java.util.Map; + +/** + * Request Payload for the /devicecode Endpoint of the Microsoft Identity Platform. Payload is used for requesting a + * devicecode for the device code flow. + * + * @see Microsoft Authentication Scheme on wiki.vg + * @see Microsoft Device + * Code Flow + */ +public final class MicrosoftAuthDeviceRequest implements RequestObject.Form { + + private final Map fields; + + public MicrosoftAuthDeviceRequest(String scope) { + Map map = new HashMap<>(); + + map.put("client_id", MsAuthAPI.clientId); + map.put("scope", scope); + + this.fields = ImmutableMap.copyOf(map); + } + + @Override + public Class getResponseClass() { + return MicrosoftAuthDeviceResponse.class; + } + + @Override + public Map getFields() { + return this.fields; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/microsoft/device/MicrosoftAuthDeviceResponse.java b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/device/MicrosoftAuthDeviceResponse.java new file mode 100644 index 0000000..eb8c60a --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/device/MicrosoftAuthDeviceResponse.java @@ -0,0 +1,59 @@ +package technicianlp.reauth.authentication.dto.microsoft.device; + +import com.google.gson.annotations.SerializedName; +import technicianlp.reauth.authentication.dto.ResponseObject; + +/** + * Response Payload for the /devicecode Endpoint of the Microsoft Identity Platform + *
+ * Only relevant fields are deserialized + * + * @see Microsoft Authentication Scheme on wiki.vg + * @see Microsoft Device + * Code Flow + */ +public final class MicrosoftAuthDeviceResponse implements ResponseObject { + + // Normal Handling + @SerializedName("device_code") + public final String deviceCode; + @SerializedName("user_code") + public final String userCode; + @SerializedName("verification_uri") + public final String verificationUri; + + @SerializedName("interval") + public final int interval; + + // Error Handling + @SerializedName("error") + public final String error; + + private MicrosoftAuthDeviceResponse() { + this.deviceCode = null; + this.userCode = null; + this.verificationUri = null; + + this.interval = 5; + + this.error = null; + } + + @Override + public boolean isValid() { + return this.error == null && this.deviceCode != null && this.userCode != null && this.verificationUri != null; + } + + @Override + public String getError() { + return this.error; + } + + public String getUserCode() { + return this.userCode; + } + + public String getVerificationUri() { + return this.verificationUri; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/microsoft/device/MicrosoftAuthDeviceTokenRequest.java b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/device/MicrosoftAuthDeviceTokenRequest.java new file mode 100644 index 0000000..6bd2c13 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/microsoft/device/MicrosoftAuthDeviceTokenRequest.java @@ -0,0 +1,42 @@ +package technicianlp.reauth.authentication.dto.microsoft.device; + +import com.google.common.collect.ImmutableMap; +import technicianlp.reauth.authentication.MsAuthAPI; +import technicianlp.reauth.authentication.dto.RequestObject; +import technicianlp.reauth.authentication.dto.microsoft.MicrosoftAuthResponse; + +import java.util.HashMap; +import java.util.Map; + +/** + * Request Payload for the /token Endpoint of the Microsoft Identity Platform. Payload is used for polling the endpoint + * as described in the device code flow. + * + * @see Microsoft Authentication Scheme on wiki.vg + * @see Microsoft Device + * Code Flow + */ +public final class MicrosoftAuthDeviceTokenRequest implements RequestObject.Form { + + private final Map fields; + + public MicrosoftAuthDeviceTokenRequest(String deviceCode) { + Map map = new HashMap<>(); + + map.put("client_id", MsAuthAPI.clientId); + map.put("device_code", deviceCode); + map.put("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); + + this.fields = ImmutableMap.copyOf(map); + } + + @Override + public Class getResponseClass() { + return MicrosoftAuthResponse.class; + } + + @Override + public Map getFields() { + return this.fields; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/mojang/MojangAuthRequest.java b/src/common/java/technicianlp/reauth/authentication/dto/mojang/MojangAuthRequest.java new file mode 100644 index 0000000..29c1b2c --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/mojang/MojangAuthRequest.java @@ -0,0 +1,24 @@ +package technicianlp.reauth.authentication.dto.mojang; + +import com.google.gson.annotations.SerializedName; +import technicianlp.reauth.authentication.dto.RequestObject; + +/** + * Request Payload for the /authentication/login_with_xbox Endpoint of the MinecraftServices API + * + * @see https://wiki.vg/Microsoft_Authentication_Scheme + */ +public final class MojangAuthRequest implements RequestObject.JSON { + + @SerializedName("identityToken") + private final String token; + + public MojangAuthRequest(String xToken, String userHash) { + this.token = "XBL3.0 x=" + userHash + ";" + xToken; + } + + @Override + public Class getResponseClass() { + return MojangAuthResponse.class; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/mojang/MojangAuthResponse.java b/src/common/java/technicianlp/reauth/authentication/dto/mojang/MojangAuthResponse.java new file mode 100644 index 0000000..2a51446 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/mojang/MojangAuthResponse.java @@ -0,0 +1,44 @@ +package technicianlp.reauth.authentication.dto.mojang; + +import com.google.gson.annotations.SerializedName; +import technicianlp.reauth.authentication.dto.ResponseObject; + +/** + * Response Payload for the /authentication/login_with_xbox Endpoint of the MinecraftServices API + *
+ * Only relevant fields are deserialized + * + * @see https://wiki.vg/Microsoft_Authentication_Scheme + */ +public final class MojangAuthResponse implements ResponseObject { + + // Normal Handling + @SerializedName("access_token") + public final String token; + @SerializedName("expires_in") + public final String expiry; + + // Error Handling + @SerializedName("error") + public final String error; + + private MojangAuthResponse() { + this.token = null; + this.expiry = null; + this.error = null; + } + + @Override + public boolean isValid() { + return this.error == null && this.token != null && this.expiry != null; + } + + @Override + public String getError() { + return this.error; + } + + public String getToken() { + return this.token; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/mojang/ProfileResponse.java b/src/common/java/technicianlp/reauth/authentication/dto/mojang/ProfileResponse.java new file mode 100644 index 0000000..e2da96d --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/mojang/ProfileResponse.java @@ -0,0 +1,40 @@ +package technicianlp.reauth.authentication.dto.mojang; + +import com.google.gson.annotations.SerializedName; +import technicianlp.reauth.authentication.dto.ResponseObject; + +/** + * Response Payload for the /minecraft/profile Endpoint of the MinecraftServices API. + *
+ * Only relevant fields are deserialized + * + * @see https://wiki.vg/Microsoft_Authentication_Scheme + */ +public final class ProfileResponse implements ResponseObject { + + // Normal Handling + @SerializedName("id") + public final String uuid; + @SerializedName("name") + public final String name; + + // Error Handling + @SerializedName("error") + public final String error; + + private ProfileResponse() { + this.uuid = null; + this.name = null; + this.error = null; + } + + @Override + public boolean isValid() { + return this.error == null && this.uuid != null && this.name != null; + } + + @Override + public String getError() { + return this.error; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/xbox/XboxAuthResponse.java b/src/common/java/technicianlp/reauth/authentication/dto/xbox/XboxAuthResponse.java new file mode 100644 index 0000000..bcdb917 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/xbox/XboxAuthResponse.java @@ -0,0 +1,96 @@ +package technicianlp.reauth.authentication.dto.xbox; + +import com.google.gson.*; +import technicianlp.reauth.authentication.dto.ResponseObject; + +import java.lang.reflect.Type; + +/** + * Response Payload for: the /user/authenticate Endpoint of the Xbox Live user service
the /xsts/authorize Endpoint + * of the Xbox Live xsts service
+ *
+ * Only relevant fields are deserialized + * + * @see https://wiki.vg/Microsoft_Authentication_Scheme + */ +public final class XboxAuthResponse implements ResponseObject { + + // Normal Handling + public final String validUntil; + public final String token; + public final String userHash; + + // Error Handling + public final String error; + + private XboxAuthResponse(String validUntil, String token, String userHash, String error) { + this.validUntil = validUntil; + this.token = token; + this.userHash = userHash; + this.error = error; + } + + @Override + public boolean isValid() { + return this.error == null && this.validUntil != null && this.token != null && this.userHash != null; + } + + @Override + public String getError() { + return this.error; + } + + public String getToken() { + return this.token; + } + + /** + * Custom deserializer that searches the DisplayClaims object for the uhs field. + */ + public static final class Deserializer implements JsonDeserializer { + + @Override + public XboxAuthResponse deserialize(JsonElement jsonElement, Type type, + @SuppressWarnings("ParameterNameDiffersFromOverriddenParameter") + JsonDeserializationContext context) throws + JsonParseException { + try { + JsonObject root = jsonElement.getAsJsonObject(); + + String validUntil = getString(root, "NotAfter"); + String token = getString(root, "Token"); + String userHash = extractUserHash(root.getAsJsonObject("DisplayClaims")); + + String error = getString(root, "XErr"); + + return new XboxAuthResponse(validUntil, token, userHash, error); + } catch (IllegalStateException | ClassCastException e) { + throw new JsonParseException("invalid format", e); + } + } + + private static String getString(JsonObject root, String name) { + JsonPrimitive primitive = root.getAsJsonPrimitive(name); + if (primitive != null) { + return primitive.getAsString(); + } else { + return null; + } + } + + private static String extractUserHash(JsonObject displayClaims) { + if (displayClaims != null) { + JsonArray xui = displayClaims.getAsJsonArray("xui"); + if (xui != null) { + for (JsonElement claim : xui) { + JsonPrimitive uhs = claim.getAsJsonObject().getAsJsonPrimitive("uhs"); + if (uhs != null) { + return uhs.getAsString(); + } + } + } + } + return null; + } + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/xbox/XboxLiveAuthRequest.java b/src/common/java/technicianlp/reauth/authentication/dto/xbox/XboxLiveAuthRequest.java new file mode 100644 index 0000000..f7e984b --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/xbox/XboxLiveAuthRequest.java @@ -0,0 +1,50 @@ +package technicianlp.reauth.authentication.dto.xbox; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import technicianlp.reauth.authentication.dto.RequestObject; + +import java.lang.reflect.Type; + +/** + * Request Payload for the /user/authenticate Endpoint of the Xbox Live user services
+ * + * @see https://wiki.vg/Microsoft_Authentication_Scheme + */ +public final class XboxLiveAuthRequest implements RequestObject.JSON { + + private final String token; + + public XboxLiveAuthRequest(String token) { + this.token = token; + } + + @Override + public Class getResponseClass() { + return XboxAuthResponse.class; + } + + /** + * Custom Serializer for {@link XboxLiveAuthRequest} for static data to improve readability + */ + public static final class Serializer implements JsonSerializer { + + @SuppressWarnings("ParameterNameDiffersFromOverriddenParameter") + @Override + public JsonElement serialize(XboxLiveAuthRequest src, Type typeOfSrc, JsonSerializationContext context) { + JsonObject properties = new JsonObject(); + properties.addProperty("AuthMethod", "RPS"); + properties.addProperty("SiteName", "user.auth.xboxlive.com"); + properties.addProperty("RpsTicket", "d=" + src.token); + + JsonObject root = new JsonObject(); + root.add("Properties", properties); + //noinspection HttpUrlsUsage + root.addProperty("RelyingParty", "http://auth.xboxlive.com"); + root.addProperty("TokenType", "JWT"); + return root; + } + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/xbox/XboxXstsAuthRequest.java b/src/common/java/technicianlp/reauth/authentication/dto/xbox/XboxXstsAuthRequest.java new file mode 100644 index 0000000..0dd4740 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/xbox/XboxXstsAuthRequest.java @@ -0,0 +1,48 @@ +package technicianlp.reauth.authentication.dto.xbox; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import technicianlp.reauth.authentication.dto.RequestObject; + +import java.lang.reflect.Type; + +/** + * Request Payload for the /user/authenticate Endpoint of the Xbox Live user services
+ * + * @see https://wiki.vg/Microsoft_Authentication_Scheme + */ +public final class XboxXstsAuthRequest implements RequestObject.JSON { + + private final String token; + + public XboxXstsAuthRequest(String token) { + this.token = token; + } + + @Override + public Class getResponseClass() { + return XboxAuthResponse.class; + } + + /** + * Custom Serializer for {@link XboxXstsAuthRequest} for static data to improve readability + */ + public static final class Serializer implements JsonSerializer { + + @SuppressWarnings("ParameterNameDiffersFromOverriddenParameter") + @Override + public JsonElement serialize(XboxXstsAuthRequest src, Type typeOfSrc, JsonSerializationContext context) { + JsonObject properties = new JsonObject(); + properties.addProperty("SandboxId", "RETAIL"); + properties.add("UserTokens", context.serialize(new String[]{src.token})); + + JsonObject root = new JsonObject(); + root.add("Properties", properties); + root.addProperty("RelyingParty", "rp://api.minecraftservices.com/"); + root.addProperty("TokenType", "JWT"); + return root; + } + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/AuthenticateRequest.java b/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/AuthenticateRequest.java new file mode 100644 index 0000000..ad8ce05 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/AuthenticateRequest.java @@ -0,0 +1,53 @@ +package technicianlp.reauth.authentication.dto.yggdrasil; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import technicianlp.reauth.authentication.dto.RequestObject; + +import java.lang.reflect.Type; + +/** + * Request Payload for the /authenticate Endpoint of the Mojang Yggdrasil API + * + * @see https://wiki.vg/Authentication + */ +public final class AuthenticateRequest implements RequestObject.JSON { + + private final String username; + private final String password; + private final String clientToken; + + public AuthenticateRequest(String username, String password, String clientToken) { + this.username = username; + this.password = password; + this.clientToken = clientToken; + } + + @Override + public Class getResponseClass() { + return AuthenticateResponse.class; + } + + /** + * Custom Serializer for {@link AuthenticateRequest} for static data to improve readability + */ + public static final class Serializer implements JsonSerializer { + + @SuppressWarnings("ParameterNameDiffersFromOverriddenParameter") + @Override + public JsonElement serialize(AuthenticateRequest src, Type typeOfSrc, JsonSerializationContext context) { + JsonObject agent = new JsonObject(); + agent.addProperty("name", "Minecraft"); + agent.addProperty("version", 1); + + JsonObject root = new JsonObject(); + root.add("agent", agent); + root.addProperty("username", src.username); + root.addProperty("password", src.password); + root.addProperty("clientToken", src.clientToken); + return root; + } + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/AuthenticateResponse.java b/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/AuthenticateResponse.java new file mode 100644 index 0000000..0b166b2 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/AuthenticateResponse.java @@ -0,0 +1,60 @@ +package technicianlp.reauth.authentication.dto.yggdrasil; + +import com.google.gson.annotations.SerializedName; +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.authentication.dto.ResponseObject; + +/** + * Response Payload for the /authenticate of the Mojang Yggdrasil API + *
+ * Only relevant fields are deserialized + * + * @see https://wiki.vg/Authentication + */ +public final class AuthenticateResponse implements ResponseObject { + + @SerializedName("accessToken") + public final String accessToken; + @SerializedName("selectedProfile") + public final Profile profile; + + @SerializedName("error") + public final String error; + + private AuthenticateResponse() { + this.accessToken = null; + this.profile = null; + this.error = null; + } + + @Override + public boolean isValid() { + return this.error == null && this.accessToken != null && this.profile != null && this.profile.name != null + && this.profile.uuid != null; + } + + @Override + public String getError() { + return this.error; + } + + public SessionData getSession() { + if (this.profile == null) { + return null; + } + return new SessionData(this.profile.name, this.profile.uuid, this.accessToken, "mojang"); + } + + public static final class Profile { + + @SerializedName("name") + public final String name; + @SerializedName("id") + public final String uuid; + + private Profile() { + this.name = null; + this.uuid = null; + } + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/JoinServerRequest.java b/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/JoinServerRequest.java new file mode 100644 index 0000000..8a79249 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/JoinServerRequest.java @@ -0,0 +1,30 @@ +package technicianlp.reauth.authentication.dto.yggdrasil; + +import com.google.gson.annotations.SerializedName; +import technicianlp.reauth.authentication.dto.RequestObject; + +/** + * Request Payload for the /session/minecraft/join of the Mojang Session API + * + * @see https://wiki.vg/Authentication + */ +public final class JoinServerRequest implements RequestObject.JSON { + + @SerializedName("accessToken") + private final String accessToken; + @SerializedName("selectedProfile") + private final String uuid; + @SerializedName("serverId") + private final String hash; + + public JoinServerRequest(String accessToken, String uuid, String hash) { + this.accessToken = accessToken; + this.uuid = uuid; + this.hash = hash; + } + + @Override + public Class getResponseClass() { + return JoinServerResponse.class; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/JoinServerResponse.java b/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/JoinServerResponse.java new file mode 100644 index 0000000..85b35f4 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/dto/yggdrasil/JoinServerResponse.java @@ -0,0 +1,29 @@ +package technicianlp.reauth.authentication.dto.yggdrasil; + +import com.google.gson.annotations.SerializedName; +import technicianlp.reauth.authentication.dto.ResponseObject; + +/** + * Response Payload for the /session/minecraft/join of the Mojang Session API Payload is only returned in case of error + * + * @see https://wiki.vg/Authentication + */ +public final class JoinServerResponse implements ResponseObject { + + @SerializedName("error") + public final String error; + + private JoinServerResponse() { + this.error = null; + } + + @Override + public boolean isValid() { + return this.error == null; + } + + @Override + public String getError() { + return this.error; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/AuthorizationCodeFlow.java b/src/common/java/technicianlp/reauth/authentication/flows/AuthorizationCodeFlow.java new file mode 100644 index 0000000..d38823a --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/AuthorizationCodeFlow.java @@ -0,0 +1,6 @@ +package technicianlp.reauth.authentication.flows; + +public interface AuthorizationCodeFlow extends Flow { + + String getLoginUrl(); +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/DeviceCodeFlow.java b/src/common/java/technicianlp/reauth/authentication/flows/DeviceCodeFlow.java new file mode 100644 index 0000000..3a80d64 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/DeviceCodeFlow.java @@ -0,0 +1,10 @@ +package technicianlp.reauth.authentication.flows; + +import java.util.concurrent.CompletableFuture; + +public interface DeviceCodeFlow extends Flow { + + CompletableFuture getLoginUrl(); + + CompletableFuture getCode(); +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/Flow.java b/src/common/java/technicianlp/reauth/authentication/flows/Flow.java new file mode 100644 index 0000000..76e7eb5 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/Flow.java @@ -0,0 +1,33 @@ +package technicianlp.reauth.authentication.flows; + +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.configuration.Profile; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +public interface Flow { + + void cancel(); + + CompletableFuture getSessionFuture(); + + boolean hasProfile(); + + /** + * Returns a {@link Profile} containing all required profile information. + * + * @throws IllegalStateException if a profile cannot be created + */ + CompletableFuture getProfileFuture(); + + default void thenRunAsync(Runnable action, Executor executor) { + CompletableFuture target; + if (this.hasProfile()) { + target = CompletableFuture.allOf(this.getSessionFuture(), this.getProfileFuture()); + } else { + target = this.getSessionFuture(); + } + target.thenRunAsync(action, executor); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/FlowCallback.java b/src/common/java/technicianlp/reauth/authentication/flows/FlowCallback.java new file mode 100644 index 0000000..addd13d --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/FlowCallback.java @@ -0,0 +1,21 @@ +package technicianlp.reauth.authentication.flows; + +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.configuration.Profile; + +import java.util.concurrent.Executor; + +public interface FlowCallback { + + /** + * transition displayed stage & log + */ + void transitionStage(FlowStage newStage); + + void onSessionComplete(SessionData session, Throwable throwable); + + void onProfileComplete(Profile profile, Throwable throwable); + + Executor getExecutor(); + +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/FlowStage.java b/src/common/java/technicianlp/reauth/authentication/flows/FlowStage.java new file mode 100644 index 0000000..833d3a8 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/FlowStage.java @@ -0,0 +1,41 @@ +package technicianlp.reauth.authentication.flows; + +public enum FlowStage { + + INITIAL("reauth.msauth.step.initial", "Starting Flow"), + FINISHED("reauth.msauth.step.finished", "Login successful"), + PROFILE("reauth.msauth.step.profile", "Saving Profile"), + FAILED("reauth.msauth.step.failed", "Login failed"), + CRYPTO_INIT("reauth.msauth.step.crypto", "Initializing Encryption"), + + YGG_AUTH("reauth.auth.step.yggdrasil", "Authenticating with Mojang Yggdrasil"), + + MS_AWAIT_AUTH_CODE("reauth.msauth.step.microsoft.code.await", "Waiting for Authentication"), + MS_REDEEM_AUTH_CODE("reauth.msauth.step.microsoft.code.redeem", "Authenticating with Microsoft"), + + MS_REQUEST_DEVICE_CODE("reauth.msauth.step.microsoft.device.request", "Requesting Device Code from Microsoft"), + MS_POLL_DEVICE_CODE("reauth.msauth.step.microsoft.device.poll", "Starting to poll Microsoft for token"), + + MS_REDEEM_REFRESH_TOKEN("reauth.msauth.step.microsoft.refresh", "Refreshing Authentication with Microsoft"), + + MS_AUTH_XASU("reauth.msauth.step.xbox", "Authenticating with Xbox Live"), + MS_AUTH_XSTS("reauth.msauth.step.xsts", "Authenticating with XSTS"), + MS_AUTH_MOJANG("reauth.msauth.step.mojang", "Authenticating with Mojang"), + MS_FETCH_PROFILE("reauth.msauth.step.fetch", "Retrieving Profile"); + + private final String rawName; + private final String logLine; + + FlowStage(String rawName, String logLine) { + this.rawName = rawName; + this.logLine = logLine; + } + + public final String getRawName() { + return this.rawName; + } + + public final String getLogLine() { + return this.logLine; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/Flows.java b/src/common/java/technicianlp/reauth/authentication/flows/Flows.java new file mode 100644 index 0000000..e9a34ab --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/Flows.java @@ -0,0 +1,26 @@ +package technicianlp.reauth.authentication.flows; + +import technicianlp.reauth.authentication.flows.impl.*; +import technicianlp.reauth.configuration.Profile; +import technicianlp.reauth.configuration.ProfileConstants; + +public enum Flows { + ; + + public static Flow loginWithProfile(Profile profile, FlowCallback callback) { + return switch (profile.getValue(ProfileConstants.PROFILE_TYPE)) { + case ProfileConstants.PROFILE_TYPE_MICROSOFT -> new MicrosoftProfileFlow(profile, callback); + case ProfileConstants.PROFILE_TYPE_MOJANG -> new MojangAuthenticationFlow(profile, callback); + default -> new UnknownProfileFlow(callback); + }; + } + + public static AuthorizationCodeFlow loginWithAuthCode(boolean persist, FlowCallback callback) { + return new MicrosoftCodeFlow(persist, callback); + } + + public static DeviceCodeFlow loginWithDeviceCode(boolean persist, FlowCallback callback) { + return new MicrosoftDeviceFlow(persist, callback); + } + +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/Tokens.java b/src/common/java/technicianlp/reauth/authentication/flows/Tokens.java new file mode 100644 index 0000000..319de4b --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/Tokens.java @@ -0,0 +1,23 @@ +package technicianlp.reauth.authentication.flows; + +import technicianlp.reauth.authentication.dto.microsoft.MicrosoftAuthResponse; +import technicianlp.reauth.authentication.dto.xbox.XboxAuthResponse; + +public final class Tokens { + + private final String xblToken; + private final String refreshToken; + + public Tokens(MicrosoftAuthResponse microsoft, XboxAuthResponse xasu) { + this.xblToken = xasu.getToken(); + this.refreshToken = microsoft.getRefreshToken(); + } + + public String getXblToken() { + return this.xblToken; + } + + public String getRefreshToken() { + return this.refreshToken; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/FlowBase.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/FlowBase.java new file mode 100644 index 0000000..6524062 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/FlowBase.java @@ -0,0 +1,113 @@ +package technicianlp.reauth.authentication.flows.impl; + +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.authentication.flows.Flow; +import technicianlp.reauth.authentication.flows.FlowCallback; +import technicianlp.reauth.authentication.flows.FlowStage; +import technicianlp.reauth.authentication.flows.impl.util.AuthBiFunction; +import technicianlp.reauth.authentication.flows.impl.util.AuthFunction; +import technicianlp.reauth.authentication.flows.impl.util.AuthSupplier; +import technicianlp.reauth.configuration.Profile; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +abstract class FlowBase implements Flow { + + private final List> stages = new ArrayList<>(); + private final List flows = new ArrayList<>(); + + private final FlowCallback callback; + final Executor executor; + + protected FlowBase(FlowCallback callback) { + this.callback = callback; + this.executor = callback.getExecutor(); + } + + /** + * Register dependant {@link CompletionStage} for cancellation handling. Every major stage should be registered for + * handling as cancelling a completed stage does not propagate to its dependants. + */ + final void registerDependantStages(CompletableFuture... stages) { + Collections.addAll(this.stages, stages); + } + + /** + * Register a dependant {@link FlowBase} for cancellation handling. Used to handle potential cleanup operations as + * the flows stages should already have been cancelled + */ + final void registerDependantFlow(FlowBase flow) { + this.stages.add(flow.getSessionFuture()); + this.flows.add(flow); + } + + /** + * Cancel the flow and its dependants. Cancels registered dependant stages in order. Requests cancellation of + * dependant flows. + */ + @Override + public void cancel() { + this.stages.forEach(stage -> stage.cancel(true)); + this.flows.forEach(FlowBase::cancel); + this.getSessionFuture().cancel(true); + } + + final void onSessionComplete(SessionData session, Throwable throwable) { + if (throwable == null) { + if (this.hasProfile() && !this.getProfileFuture().isDone()) { + this.step(FlowStage.PROFILE); + } else { + this.step(FlowStage.FINISHED); + } + } else { + this.step(FlowStage.FAILED); + } + this.callback.onSessionComplete(session, throwable); + } + + final void onProfileComplete(Profile profile, Throwable throwable) { + if (throwable == null) { + if (this.getSessionFuture().isDone() && !this.getSessionFuture().isCompletedExceptionally()) { + this.step(FlowStage.FINISHED); + } + } + this.callback.onProfileComplete(profile, throwable); + } + + final BiFunction wrapStep(FlowStage stage, AuthBiFunction step) { + return (t, u) -> { + this.step(stage); + return step.apply(t, u); + }; + } + + final Function wrapStep(FlowStage stage, AuthFunction step) { + return (t) -> { + this.step(stage); + return step.apply(t); + }; + } + + final Supplier wrapStep(FlowStage stage, AuthSupplier step) { + return () -> { + this.step(stage); + return step.get(); + }; + } + + static Function wrap(AuthFunction step) { + return step; + } + + final void step(FlowStage stage) { + this.callback.transitionStage(stage); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/MicrosoftCodeFlow.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/MicrosoftCodeFlow.java new file mode 100644 index 0000000..a024e0f --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/MicrosoftCodeFlow.java @@ -0,0 +1,97 @@ +package technicianlp.reauth.authentication.flows.impl; + +import technicianlp.reauth.authentication.MsAuthAPI; +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.authentication.dto.microsoft.MicrosoftAuthResponse; +import technicianlp.reauth.authentication.dto.xbox.XboxAuthResponse; +import technicianlp.reauth.authentication.flows.AuthorizationCodeFlow; +import technicianlp.reauth.authentication.flows.FlowCallback; +import technicianlp.reauth.authentication.flows.FlowStage; +import technicianlp.reauth.authentication.flows.Tokens; +import technicianlp.reauth.authentication.http.server.AuthenticationCodeServer; +import technicianlp.reauth.configuration.Profile; +import technicianlp.reauth.configuration.ProfileBuilder; +import technicianlp.reauth.crypto.Crypto; +import technicianlp.reauth.crypto.PkceChallenge; +import technicianlp.reauth.crypto.ProfileEncryption; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; + +public final class MicrosoftCodeFlow extends FlowBase implements AuthorizationCodeFlow { + + private final String loginUrl; + + private final CompletableFuture sessionFuture; + private final CompletableFuture profile; + + private AuthenticationCodeServer codeServer; + + public MicrosoftCodeFlow(boolean persist, FlowCallback callback) { + super(callback); + PkceChallenge pkceChallenge = Crypto.createPkceChallenge(); + this.loginUrl = MsAuthAPI.getLoginUrl(persist, pkceChallenge.getChallenge()); + + CompletableFuture codeStage = new CompletableFuture<>(); + CompletableFuture pkceVerifier = CompletableFuture.completedFuture(pkceChallenge.getVerifier()); + CompletableFuture ms = codeStage.thenCombineAsync(pkceVerifier, + this.wrapStep(FlowStage.MS_REDEEM_AUTH_CODE, MsAuthAPI::redeemAuthorizationCode), this.executor); + CompletableFuture xasu = ms.thenApply(MicrosoftAuthResponse::getAccessToken) + .thenApplyAsync(this.wrapStep(FlowStage.MS_AUTH_XASU, MsAuthAPI::authenticateXASU), this.executor); + XboxAuthenticationFlow flow = new XboxAuthenticationFlow(xasu.thenApply(XboxAuthResponse::getToken), callback); + this.sessionFuture = flow.getSessionFuture(); + this.sessionFuture.whenComplete(this::onSessionComplete); + this.registerDependantStages(codeStage, ms, xasu, this.sessionFuture); + this.registerDependantFlow(flow); + + if (persist) { + CompletableFuture tokens = ms.thenCombine(xasu, Tokens::new); + CompletableFuture encryption = + CompletableFuture.supplyAsync(Crypto::newEncryption, this.executor); + CompletableFuture builder = this.sessionFuture.thenCombine(encryption, ProfileBuilder::new); + this.profile = builder.thenCombine(tokens, ProfileBuilder::buildMicrosoft); + this.profile.whenComplete(this::onProfileComplete); + } else { + this.profile = null; + } + + this.executor.execute(() -> { + try { + this.codeServer = new AuthenticationCodeServer(MsAuthAPI.port, this.loginUrl, codeStage, this.executor); + this.step(FlowStage.MS_AWAIT_AUTH_CODE); + } catch (IOException | NoClassDefFoundError exception) { + codeStage.completeExceptionally(exception); + } + }); + } + + @Override + public CompletableFuture getSessionFuture() { + return this.sessionFuture; + } + + @Override + public boolean hasProfile() { + return this.profile != null; + } + + @Override + public CompletableFuture getProfileFuture() { + if (this.profile != null) { + return this.profile; + } else { + throw new IllegalStateException("Persistence not requested"); + } + } + + @Override + public String getLoginUrl() { + return this.loginUrl; + } + + @Override + public void cancel() { + super.cancel(); + this.executor.execute(() -> this.codeServer.stop(true)); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/MicrosoftDeviceFlow.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/MicrosoftDeviceFlow.java new file mode 100644 index 0000000..3f51a2a --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/MicrosoftDeviceFlow.java @@ -0,0 +1,129 @@ +package technicianlp.reauth.authentication.flows.impl; + +import technicianlp.reauth.ReAuth; +import technicianlp.reauth.authentication.MsAuthAPI; +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.authentication.dto.microsoft.MicrosoftAuthResponse; +import technicianlp.reauth.authentication.dto.microsoft.device.MicrosoftAuthDeviceResponse; +import technicianlp.reauth.authentication.dto.xbox.XboxAuthResponse; +import technicianlp.reauth.authentication.flows.DeviceCodeFlow; +import technicianlp.reauth.authentication.flows.FlowCallback; +import technicianlp.reauth.authentication.flows.FlowStage; +import technicianlp.reauth.authentication.flows.Tokens; +import technicianlp.reauth.authentication.http.InvalidResponseException; +import technicianlp.reauth.authentication.http.Response; +import technicianlp.reauth.authentication.http.UnreachableServiceException; +import technicianlp.reauth.configuration.Profile; +import technicianlp.reauth.configuration.ProfileBuilder; +import technicianlp.reauth.crypto.Crypto; +import technicianlp.reauth.crypto.ProfileEncryption; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; + +public final class MicrosoftDeviceFlow extends FlowBase implements DeviceCodeFlow { + + private final CompletableFuture sessionFuture; + private final CompletableFuture profile; + + private final CompletableFuture loginUrl; + private final CompletableFuture code; + + private final CompletableFuture auth; + + public MicrosoftDeviceFlow(boolean persist, FlowCallback callback) { + super(callback); + + CompletableFuture deviceResponse = CompletableFuture.completedFuture(persist) + .thenApplyAsync(this.wrapStep(FlowStage.MS_REQUEST_DEVICE_CODE, MsAuthAPI::requestDeviceCode), + this.executor); + this.loginUrl = deviceResponse.thenApply(MicrosoftAuthDeviceResponse::getVerificationUri); + this.code = deviceResponse.thenApply(MicrosoftAuthDeviceResponse::getUserCode); + this.auth = deviceResponse.thenApplyAsync(this::pollForCode, this.executor); + CompletableFuture xasu = this.auth.thenApply(MicrosoftAuthResponse::getAccessToken) + .thenApplyAsync(this.wrapStep(FlowStage.MS_AUTH_XASU, MsAuthAPI::authenticateXASU), this.executor); + + XboxAuthenticationFlow flow = new XboxAuthenticationFlow(xasu.thenApply(XboxAuthResponse::getToken), callback); + this.sessionFuture = flow.getSessionFuture(); + this.sessionFuture.whenComplete(this::onSessionComplete); + this.registerDependantStages(deviceResponse, this.auth, xasu, this.sessionFuture); + this.registerDependantFlow(flow); + + if (persist) { + CompletableFuture tokens = this.auth.thenCombine(xasu, Tokens::new); + CompletableFuture encryption = + CompletableFuture.supplyAsync(Crypto::newEncryption, this.executor); + CompletableFuture builder = this.sessionFuture.thenCombine(encryption, ProfileBuilder::new); + this.profile = builder.thenCombine(tokens, ProfileBuilder::buildMicrosoft); + this.profile.whenComplete(this::onProfileComplete); + } else { + this.profile = null; + } + } + + /** + * Poll the Microsoft Token Endpoint until the user has completed authentication. Interval between Polls is + * specified by {@link MicrosoftAuthDeviceResponse#interval}. + */ + private MicrosoftAuthResponse pollForCode(MicrosoftAuthDeviceResponse deviceResponse) { + this.step(FlowStage.MS_POLL_DEVICE_CODE); + while (!this.auth.isDone()) { + ReAuth.log.debug("Polling Microsoft for token"); + try { + Response response = MsAuthAPI.redeemDeviceCode(deviceResponse.deviceCode); + if (response.isValid()) { + ReAuth.log.info("Authorization received"); + return response.get(); + } else { + MicrosoftAuthResponse responseError = response.getResponse(); + if ("authorization_pending".equals(responseError.getError())) { + ReAuth.log.debug("Authorization is still pending - continue polling"); + } else { + ReAuth.log.info("Authorization failed: {}", responseError.getError()); + // will throw InvalidResponseException + return response.get(); + } + } + } catch (UnreachableServiceException | InvalidResponseException e) { + throw new CompletionException(e); + } + try { + TimeUnit.SECONDS.sleep(deviceResponse.interval); + } catch (InterruptedException e) { + throw new CompletionException(e); + } + } + // Polling cancelled + return null; + } + + @Override + public CompletableFuture getSessionFuture() { + return this.sessionFuture; + } + + @Override + public boolean hasProfile() { + return this.profile != null; + } + + @Override + public CompletableFuture getProfileFuture() { + if (this.profile != null) { + return this.profile; + } else { + throw new IllegalStateException("Persistence not requested"); + } + } + + @Override + public CompletableFuture getLoginUrl() { + return this.loginUrl; + } + + @Override + public CompletableFuture getCode() { + return this.code; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/MicrosoftProfileFlow.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/MicrosoftProfileFlow.java new file mode 100644 index 0000000..81290c5 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/MicrosoftProfileFlow.java @@ -0,0 +1,128 @@ +package technicianlp.reauth.authentication.flows.impl; + +import technicianlp.reauth.authentication.MsAuthAPI; +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.authentication.dto.microsoft.MicrosoftAuthResponse; +import technicianlp.reauth.authentication.dto.xbox.XboxAuthResponse; +import technicianlp.reauth.authentication.flows.FlowCallback; +import technicianlp.reauth.authentication.flows.FlowStage; +import technicianlp.reauth.authentication.flows.Tokens; +import technicianlp.reauth.authentication.flows.impl.util.Futures; +import technicianlp.reauth.configuration.Profile; +import technicianlp.reauth.configuration.ProfileBuilder; +import technicianlp.reauth.configuration.ProfileConstants; +import technicianlp.reauth.crypto.Crypto; +import technicianlp.reauth.crypto.ProfileEncryption; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.BiConsumer; +import java.util.function.Function; + +public final class MicrosoftProfileFlow extends FlowBase { + + private final CompletableFuture encryption; + private final CompletableFuture session; + + private final XboxAuthenticationFlow xboxFlow1; + + private final CompletableFuture refreshRequired; + private final CompletableFuture profileFuture; + + /** + * tries to log in using the stored accessToken. When authentication fails due to an expired token, the stored + * refreshToken is used to acquire a new accessToken followed by a second login attempt + * + * @see XboxAuthenticationFlow#hasExpiredTokenError() + */ + public MicrosoftProfileFlow(Profile profile, FlowCallback callback) { + super(callback); + + this.session = new CompletableFuture<>(); + this.session.whenComplete(this::onSessionComplete); + CompletableFuture profileFuture = CompletableFuture.completedFuture(profile); + this.encryption = + profileFuture.thenApplyAsync(this.wrapStep(FlowStage.CRYPTO_INIT, Crypto::getProfileEncryption), + this.executor); + this.refreshRequired = new CompletableFuture<>(); + + CompletableFuture xblTokenDec = + this.encryption.thenCombineAsync(profile.get(ProfileConstants.XBL_TOKEN), + ProfileEncryption::decryptFieldOne, this.executor); + this.xboxFlow1 = new XboxAuthenticationFlow(xblTokenDec, callback); + this.xboxFlow1.getSessionFuture().whenComplete(this::onComplete); + this.registerDependantStages(this.encryption, xblTokenDec); + this.registerDependantFlow(this.xboxFlow1); + + CompletableFuture refreshTokenEnc = this.refreshRequired.thenCompose( + Futures.conditional(profile.get(ProfileConstants.REFRESH_TOKEN), Futures.cancelled())); + CompletableFuture refreshTokenDec = + this.encryption.thenCombineAsync(refreshTokenEnc, ProfileEncryption::decryptFieldTwo, this.executor); + CompletableFuture auth = refreshTokenDec.thenApplyAsync( + this.wrapStep(FlowStage.MS_REDEEM_REFRESH_TOKEN, MsAuthAPI::redeemRefreshToken), this.executor); + CompletableFuture xasu = auth.thenApply(MicrosoftAuthResponse::getAccessToken) + .thenApplyAsync(this.wrapStep(FlowStage.MS_AUTH_XASU, MsAuthAPI::authenticateXASU), this.executor); + CompletableFuture xblToken = xasu.thenApply(XboxAuthResponse::getToken); + XboxAuthenticationFlow xboxFlow2 = new XboxAuthenticationFlow(xblToken, callback); + xboxFlow2.getSessionFuture().whenComplete(this::onComplete); + this.registerDependantStages(this.refreshRequired, refreshTokenDec, auth); + this.registerDependantFlow(xboxFlow2); + + CompletableFuture tokens = auth.thenCombine(xasu, Tokens::new); + this.profileFuture = this.refreshRequired.thenComposeAsync( + Futures.conditional(() -> this.constructProfile(tokens), CompletableFuture.completedFuture(profile)), + this.executor); + this.profileFuture.whenComplete(this::onProfileComplete); + } + + /** + * A reimagined version of {@link CompletableFuture#applyToEither(CompletionStage, Function)} that makes guarantees + * on exceptional behaviour and triggers the fallback computation if required. + *

+ * completes {@link MicrosoftProfileFlow#session} with the provided session if completed normally (throwable = + * null)
if the first {@link XboxAuthenticationFlow} completed exceptionally caused by an expired token the + * refresh-flow is triggered by completing {@link MicrosoftProfileFlow#refreshRequired}
otherwise + * {@link MicrosoftProfileFlow#session} is completed exceptionally with the supplied throwable. + * + * @see CompletionStage#whenComplete(BiConsumer) + * @see CompletableFuture#applyToEither(CompletionStage, Function) + */ + private void onComplete(SessionData sessionData, Throwable throwable) { + if (throwable == null) { + this.session.complete(sessionData); + this.refreshRequired.complete(false); + } else { + if (this.refreshRequired.isDone()) { + this.session.completeExceptionally(throwable); + } + if (this.xboxFlow1.hasExpiredTokenError()) { + this.refreshRequired.complete(true); + } else { + this.session.completeExceptionally(throwable); + this.refreshRequired.complete(false); + } + } + } + + @Override + public CompletableFuture getSessionFuture() { + return this.session; + } + + @Override + public boolean hasProfile() { + return true; + } + + @Override + public CompletableFuture getProfileFuture() { + return this.profileFuture; + } + + private CompletableFuture constructProfile(CompletableFuture tokens) { + CompletableFuture encryption = + this.encryption.thenApplyAsync(ProfileEncryption::randomizedCopy, this.executor); + CompletableFuture builder = this.session.thenCombine(encryption, ProfileBuilder::new); + return builder.thenCombineAsync(tokens, ProfileBuilder::buildMicrosoft, this.executor); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/MojangAuthenticationFlow.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/MojangAuthenticationFlow.java new file mode 100644 index 0000000..0d78f0d --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/MojangAuthenticationFlow.java @@ -0,0 +1,77 @@ +package technicianlp.reauth.authentication.flows.impl; + +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.authentication.YggdrasilAPI; +import technicianlp.reauth.authentication.flows.FlowCallback; +import technicianlp.reauth.authentication.flows.FlowStage; +import technicianlp.reauth.configuration.Profile; +import technicianlp.reauth.configuration.ProfileBuilder; +import technicianlp.reauth.configuration.ProfileConstants; +import technicianlp.reauth.crypto.Crypto; +import technicianlp.reauth.crypto.ProfileEncryption; + +import java.util.concurrent.CompletableFuture; + +public final class MojangAuthenticationFlow extends FlowBase { + + private final CompletableFuture session; + + private final CompletableFuture profile; + + public MojangAuthenticationFlow(String username, String password, boolean save, FlowCallback callback) { + super(callback); + this.session = CompletableFuture.supplyAsync( + this.wrapStep(FlowStage.YGG_AUTH, () -> YggdrasilAPI.login(username, password)), this.executor); + this.session.whenComplete(this::onSessionComplete); + this.registerDependantStages(this.session); + + if (save) { + CompletableFuture encryption = + CompletableFuture.supplyAsync(Crypto::newEncryption, this.executor); + CompletableFuture builderFuture = this.session.thenCombine(encryption, ProfileBuilder::new); + this.profile = builderFuture.thenApply(builder -> builder.buildMojang(username, password)); + } else { + this.profile = null; + } + } + + public MojangAuthenticationFlow(Profile profile, FlowCallback callback) { + super(callback); + CompletableFuture profileFuture = CompletableFuture.completedFuture(profile); + CompletableFuture encryption = + profileFuture.thenApplyAsync(this.wrapStep(FlowStage.CRYPTO_INIT, Crypto::getProfileEncryption), + this.executor); + CompletableFuture usernameDec = + encryption.thenCombineAsync(profile.get(ProfileConstants.USERNAME), ProfileEncryption::decryptFieldOne, + this.executor); + CompletableFuture passwordDec = + encryption.thenCombineAsync(profile.get(ProfileConstants.PASSWORD), ProfileEncryption::decryptFieldTwo, + this.executor); + + this.session = usernameDec.thenCombineAsync(passwordDec, this.wrapStep(FlowStage.YGG_AUTH, YggdrasilAPI::login), + this.executor); + this.registerDependantStages(encryption, usernameDec, passwordDec, this.session); + + this.profile = CompletableFuture.completedFuture(profile); + this.profile.whenComplete(this::onProfileComplete); + } + + @Override + public CompletableFuture getSessionFuture() { + return this.session; + } + + @Override + public boolean hasProfile() { + return this.profile != null; + } + + @Override + public CompletableFuture getProfileFuture() { + if (this.profile != null) { + return this.profile; + } else { + throw new IllegalStateException("Persistence not requested"); + } + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/UnknownProfileFlow.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/UnknownProfileFlow.java new file mode 100644 index 0000000..c5f1d21 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/UnknownProfileFlow.java @@ -0,0 +1,34 @@ +package technicianlp.reauth.authentication.flows.impl; + +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.authentication.flows.FlowCallback; +import technicianlp.reauth.authentication.flows.impl.util.Futures; +import technicianlp.reauth.configuration.Profile; + +import java.util.concurrent.CompletableFuture; + +public final class UnknownProfileFlow extends FlowBase { + + private final CompletableFuture sessionFuture; + + public UnknownProfileFlow(FlowCallback callback) { + super(callback); + this.sessionFuture = Futures.failed(new IllegalArgumentException("Unknown Profile Type")); + this.sessionFuture.whenComplete(this::onSessionComplete); + } + + @Override + public CompletableFuture getSessionFuture() { + return this.sessionFuture; + } + + @Override + public boolean hasProfile() { + return false; + } + + @Override + public CompletableFuture getProfileFuture() { + throw new IllegalStateException("Profile creation not supported"); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/XboxAuthenticationFlow.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/XboxAuthenticationFlow.java new file mode 100644 index 0000000..13cfdaf --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/XboxAuthenticationFlow.java @@ -0,0 +1,89 @@ +package technicianlp.reauth.authentication.flows.impl; + +import technicianlp.reauth.ReAuth; +import technicianlp.reauth.authentication.MsAuthAPI; +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.authentication.dto.mojang.MojangAuthResponse; +import technicianlp.reauth.authentication.dto.mojang.ProfileResponse; +import technicianlp.reauth.authentication.dto.xbox.XboxAuthResponse; +import technicianlp.reauth.authentication.flows.FlowCallback; +import technicianlp.reauth.authentication.flows.FlowStage; +import technicianlp.reauth.authentication.http.InvalidResponseException; +import technicianlp.reauth.authentication.http.Response; +import technicianlp.reauth.authentication.http.UnreachableServiceException; +import technicianlp.reauth.configuration.Profile; + +import java.util.concurrent.CompletableFuture; + +final class XboxAuthenticationFlow extends FlowBase { + + private static final String XSTS_ERR_TOKEN_EXPIRED = Integer.toUnsignedString(0x8015DC22); + private static final String XSTS_ERR_TOKEN_INVALID = Integer.toUnsignedString(0x8015DC26); + + private final CompletableFuture session; + private final CompletableFuture> xstsAuthResponse; + + XboxAuthenticationFlow(CompletableFuture xblToken, FlowCallback callback) { + super(callback); + + this.xstsAuthResponse = + xblToken.thenApplyAsync(this.wrapStep(FlowStage.MS_AUTH_XSTS, MsAuthAPI::authenticateXSTS), this.executor); + CompletableFuture xsts = this.xstsAuthResponse.thenApply(FlowBase.wrap(Response::get)); + CompletableFuture mojang = + xsts.thenApplyAsync(this.wrapStep(FlowStage.MS_AUTH_MOJANG, XboxAuthenticationFlow::authenticateMojang), + this.executor); + CompletableFuture token = mojang.thenApply(MojangAuthResponse::getToken); + CompletableFuture profile = + token.thenApplyAsync(this.wrapStep(FlowStage.MS_FETCH_PROFILE, MsAuthAPI::fetchProfile), this.executor); + this.session = token.thenCombine(profile, XboxAuthenticationFlow::makeSession); + + this.registerDependantStages(this.xstsAuthResponse, xsts, mojang, profile, this.session); + } + + private static MojangAuthResponse authenticateMojang(XboxAuthResponse xsts) throws UnreachableServiceException, + InvalidResponseException { + return MsAuthAPI.authenticateMojang(xsts.token, xsts.userHash); + } + + private static SessionData makeSession(String token, ProfileResponse profile) { + return new SessionData(profile.name, profile.uuid, token, "msa"); + } + + @Override + public CompletableFuture getSessionFuture() { + return this.session; + } + + /** + * checks whether the passed response contains an error caused expired/invalid XASU-Token. If a valid response is + * passed or the request failed with an exception false is returned. + */ + boolean hasExpiredTokenError() { + if (!this.xstsAuthResponse.isDone()) { + ReAuth.log.warn("Cant determine token expiration on unfinished request"); + return false; + } + if (this.xstsAuthResponse.isCompletedExceptionally()) { + return false; + } + Response response = this.xstsAuthResponse.join(); + if (response.isValid()) { + return false; + } + XboxAuthResponse rawResponse = response.getResponse(); + if (rawResponse != null) { + return XSTS_ERR_TOKEN_EXPIRED.equals(rawResponse.error) || XSTS_ERR_TOKEN_INVALID.equals(rawResponse.error); + } + return false; + } + + @Override + public boolean hasProfile() { + return false; + } + + @Override + public CompletableFuture getProfileFuture() { + throw new IllegalStateException("Profile creation not supported"); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/util/AuthBiFunction.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/util/AuthBiFunction.java new file mode 100644 index 0000000..fd375d6 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/util/AuthBiFunction.java @@ -0,0 +1,28 @@ +package technicianlp.reauth.authentication.flows.impl.util; + +import technicianlp.reauth.authentication.http.InvalidResponseException; +import technicianlp.reauth.authentication.http.UnreachableServiceException; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.BiFunction; + +/** + * Functional interface that allows a method reference to throw {@link UnreachableServiceException} or + * {@link InvalidResponseException}. Thrown Exceptions will be wrapped with a {@link CompletionException} for use with + * {@link CompletableFuture}. + */ +@FunctionalInterface +public interface AuthBiFunction extends BiFunction { + + R applyAuthStep(T t, U u) throws UnreachableServiceException, InvalidResponseException; + + @Override + default R apply(T t, U u) { + try { + return this.applyAuthStep(t, u); + } catch (UnreachableServiceException | InvalidResponseException e) { + throw new CompletionException(e); + } + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/util/AuthFunction.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/util/AuthFunction.java new file mode 100644 index 0000000..af2a1f6 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/util/AuthFunction.java @@ -0,0 +1,28 @@ +package technicianlp.reauth.authentication.flows.impl.util; + +import technicianlp.reauth.authentication.http.InvalidResponseException; +import technicianlp.reauth.authentication.http.UnreachableServiceException; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Function; + +/** + * Functional interface that allows a method reference to throw {@link UnreachableServiceException} or + * {@link InvalidResponseException}. Thrown Exceptions will be wrapped with a {@link CompletionException} for use with + * {@link CompletableFuture}. + */ +@FunctionalInterface +public interface AuthFunction extends Function { + + R applyAuthStep(T t) throws UnreachableServiceException, InvalidResponseException; + + @Override + default R apply(T t) { + try { + return this.applyAuthStep(t); + } catch (UnreachableServiceException | InvalidResponseException e) { + throw new CompletionException(e); + } + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/util/AuthSupplier.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/util/AuthSupplier.java new file mode 100644 index 0000000..412d45e --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/util/AuthSupplier.java @@ -0,0 +1,28 @@ +package technicianlp.reauth.authentication.flows.impl.util; + +import technicianlp.reauth.authentication.http.InvalidResponseException; +import technicianlp.reauth.authentication.http.UnreachableServiceException; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Supplier; + +/** + * Functional interface that allows a method reference to throw {@link UnreachableServiceException} or + * {@link InvalidResponseException}. Thrown Exceptions will be wrapped with a {@link CompletionException} for use with + * {@link CompletableFuture}. + */ +@FunctionalInterface +public interface AuthSupplier extends Supplier { + + T getAuthStep() throws UnreachableServiceException, InvalidResponseException; + + @Override + default T get() { + try { + return this.getAuthStep(); + } catch (UnreachableServiceException | InvalidResponseException e) { + throw new CompletionException(e); + } + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/flows/impl/util/Futures.java b/src/common/java/technicianlp/reauth/authentication/flows/impl/util/Futures.java new file mode 100644 index 0000000..e734e75 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/flows/impl/util/Futures.java @@ -0,0 +1,32 @@ +package technicianlp.reauth.authentication.flows.impl.util; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.function.Supplier; + +public enum Futures { + ; + + public static Function> conditional(CompletableFuture value, + CompletableFuture fallback) { + return condition -> condition ? value : fallback; + } + + public static Function> conditional( + Supplier> value, + CompletableFuture fallback) { + return condition -> condition ? value.get() : fallback; + } + + public static CompletableFuture failed(Throwable throwable) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(throwable); + return future; + } + + public static CompletableFuture cancelled() { + CompletableFuture future = new CompletableFuture<>(); + future.cancel(true); + return future; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/HttpUtil.java b/src/common/java/technicianlp/reauth/authentication/http/HttpUtil.java new file mode 100644 index 0000000..c478203 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/HttpUtil.java @@ -0,0 +1,131 @@ +package technicianlp.reauth.authentication.http; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import technicianlp.reauth.authentication.dto.RequestObject; +import technicianlp.reauth.authentication.dto.ResponseObject; +import technicianlp.reauth.authentication.dto.xbox.XboxAuthResponse; +import technicianlp.reauth.authentication.dto.xbox.XboxLiveAuthRequest; +import technicianlp.reauth.authentication.dto.xbox.XboxXstsAuthRequest; +import technicianlp.reauth.authentication.dto.yggdrasil.AuthenticateRequest; +import technicianlp.reauth.mojangfix.CertWorkaround; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; +import java.io.*; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.stream.Collectors; + +public enum HttpUtil { + ; + + private static final Gson GSON; + + static { + GsonBuilder builder = new GsonBuilder(); + builder.registerTypeAdapter(AuthenticateRequest.class, new AuthenticateRequest.Serializer()); + builder.registerTypeAdapter(XboxLiveAuthRequest.class, new XboxLiveAuthRequest.Serializer()); + builder.registerTypeAdapter(XboxXstsAuthRequest.class, new XboxXstsAuthRequest.Serializer()); + builder.registerTypeAdapter(XboxAuthResponse.class, new XboxAuthResponse.Deserializer()); + GSON = builder.create(); + } + + public static R performFormRequest(String url, RequestObject.Form form) throws + UnreachableServiceException, InvalidResponseException { + return performWrappedFormRequest(url, form).get(); + } + + public static Response performWrappedFormRequest(String url, + RequestObject.Form form) throws + UnreachableServiceException { + String body = form.getFields().entrySet().stream().map(HttpUtil::urlEncode).collect(Collectors.joining("&")); + return performRequest(url, body, "application/x-www-form-urlencoded", null, form.getResponseClass()); + } + + public static R performJsonRequest(String url, RequestObject.JSON payload) throws + UnreachableServiceException, InvalidResponseException { + return performWrappedJsonRequest(url, payload).get(); + } + + public static Response performWrappedJsonRequest(String url, + RequestObject.JSON payload) throws + UnreachableServiceException { + return performRequest(url, GSON.toJson(payload), "application/json", null, payload.getResponseClass()); + } + + public static R performGetRequest(String url, String bearer, + Class responseType) throws + UnreachableServiceException, InvalidResponseException { + return performWrappedGetRequest(url, bearer, responseType).get(); + } + + public static Response performWrappedGetRequest(String url, String bearer, + Class responseType) throws + UnreachableServiceException { + return performRequest(url, null, null, bearer, responseType); + } + + /** + * Executes the Request and parses the returned Response. + *

+ * {@link IllegalStateException} can occur in GSON when type-mismatches are encountered + */ + private static Response performRequest(String url, String body, String contentType, + String token, Class responseType) throws + UnreachableServiceException { + try { + HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection(); + + SSLSocketFactory socketFactory = CertWorkaround.getSocketFactory(); + if (socketFactory != null) { + connection.setSSLSocketFactory(socketFactory); + } + + connection.setConnectTimeout(10000); + connection.setReadTimeout(10000); + connection.setInstanceFollowRedirects(true); + connection.setUseCaches(false); + + connection.setRequestProperty("Accept", "application/json"); + if (token != null) { + connection.setRequestProperty("Authorization", "Bearer " + token); + } + if (body != null) { + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", contentType); + connection.setDoOutput(true); + try (OutputStream dataOut = connection.getOutputStream()) { + dataOut.write(body.getBytes(StandardCharsets.UTF_8)); + dataOut.flush(); + } + } + + connection.connect(); + + int responseCode = connection.getResponseCode(); + InputStream inputStream = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); + + R response = null; + if (inputStream.available() > 0) { + try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { + response = GSON.fromJson(reader, responseType); + } + } + + return new Response<>(responseCode, response); + } catch (ClassCastException | IOException e) { + throw new UnreachableServiceException("Cannot reach server", e); + } catch (IllegalStateException | JsonParseException e) { + throw new UnreachableServiceException("Server is talking nonsense", e); + } + } + + private static String urlEncode(Map.Entry entry) { + return URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + "=" + URLEncoder.encode(entry.getValue(), + StandardCharsets.UTF_8); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/InvalidResponseException.java b/src/common/java/technicianlp/reauth/authentication/http/InvalidResponseException.java new file mode 100644 index 0000000..14b4197 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/InvalidResponseException.java @@ -0,0 +1,8 @@ +package technicianlp.reauth.authentication.http; + +public final class InvalidResponseException extends Exception { + + public InvalidResponseException(String message) { + super(message); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/Response.java b/src/common/java/technicianlp/reauth/authentication/http/Response.java new file mode 100644 index 0000000..cb1f595 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/Response.java @@ -0,0 +1,42 @@ +package technicianlp.reauth.authentication.http; + +import technicianlp.reauth.authentication.dto.ResponseObject; + +import java.net.HttpURLConnection; + +public final class Response { + + private final int statusCode; + private final R response; + + public Response(int statusCode, R response) { + this.statusCode = statusCode; + this.response = response; + } + + public boolean isValid() { + if (!(200 <= this.statusCode && this.statusCode < 300)) { + return false; + } + if (this.response != null) { + return this.response.isValid(); + } else { + return this.statusCode == HttpURLConnection.HTTP_NO_CONTENT; + } + } + + public R get() throws InvalidResponseException { + if (this.isValid()) { + return this.response; + } else if (this.response != null) { + throw new InvalidResponseException( + "Error received with code " + this.statusCode + ": " + this.response.getError()); + } else { + throw new InvalidResponseException("Received error code " + this.statusCode); + } + } + + public R getResponse() { + return this.response; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/UnreachableServiceException.java b/src/common/java/technicianlp/reauth/authentication/http/UnreachableServiceException.java new file mode 100644 index 0000000..fea0c91 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/UnreachableServiceException.java @@ -0,0 +1,11 @@ +package technicianlp.reauth.authentication.http; + +/** + * Wrapper Exception for use when services cannot be reached or answers cannot be decoded + */ +public final class UnreachableServiceException extends Exception { + + UnreachableServiceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/server/AuthenticationCodeServer.java b/src/common/java/technicianlp/reauth/authentication/http/server/AuthenticationCodeServer.java new file mode 100644 index 0000000..aa74e4e --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/server/AuthenticationCodeServer.java @@ -0,0 +1,71 @@ +package technicianlp.reauth.authentication.http.server; + +import com.sun.net.httpserver.HttpServer; +import technicianlp.reauth.ReAuth; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Authentication with Microsoft requires a webserver on localhost to receive the issued authentication code. + *
+ * Class provides encapsulation of the jdk-builtin {@link HttpServer}. The API provided by the + * {@link com.sun.net.httpserver} package is an official and documented API since Java 6. While the API should be + * available in every major java distribution, it is an optional API and may therefore be missing in rare cases. This is + * denoted by both the {@code jdk.Exported} Annotation and + * online documentation. + *
+ * The HttpServer is started on the supplied Executor. After the code has been received the future is completed + * synchronously and the server automatically {@link #stop(boolean) stops} asynchronously. + */ +public final class AuthenticationCodeServer { + + private final Consumer stopServer; + + private boolean running = true; + + public AuthenticationCodeServer(int port, String loginUrl, CompletableFuture codeFuture, + Executor executor) throws IOException, NoClassDefFoundError { + InetSocketAddress localAddress = new InetSocketAddress(InetAddress.getLoopbackAddress(), port); + HttpServer server = HttpServer.create(localAddress, 0); + server.setExecutor(executor); + + PageWriter writer = new PageWriter(loginUrl); + server.createContext("/", new CodeHandler(writer, codeFuture)); + server.createContext("/res/", new ResourcesHandler(writer)); + + codeFuture.whenCompleteAsync((v, exception) -> this.stop(exception != null), executor); + + ReAuth.log.info("Starting local endpoint"); + server.start(); + ReAuth.log.info("Started local endpoint"); + + this.stopServer = (immediate) -> server.stop(immediate ? 0 : 1); + } + + public void stop(boolean immediate) { + synchronized (this) { + if (this.running) { + this.running = false; + } else { + return; + } + } + if (!immediate) { + ReAuth.log.info("About to stop local endpoint"); + try { + TimeUnit.SECONDS.sleep(1); + } catch (InterruptedException exception) { + ReAuth.log.warn("Interrupted while waiting to stop local endpoint", exception); + } + } + ReAuth.log.info("Stopping local endpoint"); + this.stopServer.accept(immediate); + ReAuth.log.info("Stopped local endpoint"); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/server/CodeHandler.java b/src/common/java/technicianlp/reauth/authentication/http/server/CodeHandler.java new file mode 100644 index 0000000..4687fbd --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/server/CodeHandler.java @@ -0,0 +1,92 @@ +package technicianlp.reauth.authentication.http.server; + +import com.sun.net.httpserver.HttpExchange; +import org.apache.commons.io.IOUtils; +import technicianlp.reauth.ReAuth; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +final class CodeHandler extends Handler { + + private final CompletableFuture codeFuture; + + CodeHandler(PageWriter pageWriter, CompletableFuture codeFuture) { + super(pageWriter); + this.codeFuture = codeFuture; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + + String method = exchange.getRequestMethod().toUpperCase(Locale.ROOT); + Response response; + if ("POST".equals(method)) { + response = this.handlePostRequest(exchange); + } else if ("HEAD".equals(method) || "GET".equals(method)) { + response = new Response(HttpStatus.Method_Not_Allowed).setHeader("Allow", "POST"); + } else { + response = new Response(HttpStatus.Not_Implemented); + } + + this.sendResponse(exchange, response); + } catch (Exception exception) { + ReAuth.log.error("Exception while answering request", exception); + throw exception; + } + } + + private Response handlePostRequest(HttpExchange exchange) throws IOException { + String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); + if (!contentType.startsWith("application/x-www-form-urlencoded")) { + return new Response(HttpStatus.Unsupported_Media_Type); + } + + String body = IOUtils.toString(exchange.getRequestBody(), StandardCharsets.UTF_8); + Map formFields = (parseFormFields(body)); + + if (formFields.containsKey("code")) { + ReAuth.log.info("Received Microsoft Authentication Code"); + this.codeFuture.complete(formFields.get("code")); + return new Response(HttpStatus.OK).setContent(CONTENT_TYPE_HTML, + PageWriter.createSuccessResponsePage()); + } else { + String error = formFields.getOrDefault("error", "unknown"); + ReAuth.log.error("Received Error from Microsoft Authentication: {}", error); + return new Response(HttpStatus.Bad_Request).setContent(CONTENT_TYPE_HTML, + this.pageWriter.createErrorResponsePage(error)); + } + } + + /** + * Decodes the Contents of an application/x-www-form-urlencoded request. Duplicate field names are discarded. + */ + private static Map parseFormFields(String formUrlEncoded) { + Map formFields = new HashMap<>(); + String[] fields = formUrlEncoded.split("&"); + + for (String field : fields) { + if (field.isEmpty()) { + continue; + } + String key = field; + String value = ""; + + int delimiter = field.indexOf('='); + if (delimiter != -1) { + key = field.substring(0, delimiter); + value = field.substring(delimiter + 1); + } + + formFields.putIfAbsent(URLDecoder.decode(key, StandardCharsets.UTF_8), URLDecoder.decode(value, + StandardCharsets.UTF_8)); + } + return formFields; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/server/Handler.java b/src/common/java/technicianlp/reauth/authentication/http/server/Handler.java new file mode 100644 index 0000000..812bf0d --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/server/Handler.java @@ -0,0 +1,40 @@ +package technicianlp.reauth.authentication.http.server; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; + +abstract class Handler implements HttpHandler { + + static final int BUFFER_SIZE = 8192; + static final String CONTENT_TYPE_HTML = "text/html; charset=UTF-8"; + + final PageWriter pageWriter; + + Handler(PageWriter pageWriter) { + this.pageWriter = pageWriter; + } + + final void sendResponse(HttpExchange exchange, Response response) throws IOException { + Headers responseHeaders = exchange.getResponseHeaders(); + response.getHeaders().forEach(responseHeaders::set); + + if ("HEAD".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(response.getHttpStatus().code, -1); + } else { + if (!response.hasContent()) { + ByteBuffer buffer = this.pageWriter.createHttpErrorResponsePage(response.getHttpStatus()); + response.setContent(CONTENT_TYPE_HTML, buffer); + } + ByteBuffer pageContent = response.getPageContent(); + pageContent.flip(); + exchange.sendResponseHeaders(response.getHttpStatus().code, pageContent.limit()); + Channels.newChannel(exchange.getResponseBody()).write(pageContent); + } + exchange.close(); + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/server/HttpStatus.java b/src/common/java/technicianlp/reauth/authentication/http/server/HttpStatus.java new file mode 100644 index 0000000..68121ee --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/server/HttpStatus.java @@ -0,0 +1,17 @@ +package technicianlp.reauth.authentication.http.server; + +enum HttpStatus { + + OK(200), + Bad_Request(400), + Not_Found(404), + Method_Not_Allowed(405), + Unsupported_Media_Type(415), + Not_Implemented(501); + + final int code; + + HttpStatus(int code) { + this.code = code; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/server/PageWriter.java b/src/common/java/technicianlp/reauth/authentication/http/server/PageWriter.java new file mode 100644 index 0000000..2d76e3c --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/server/PageWriter.java @@ -0,0 +1,78 @@ +package technicianlp.reauth.authentication.http.server; + +import org.apache.commons.io.IOUtils; +import technicianlp.reauth.ReAuth; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +final class PageWriter { + + private static final int BUFFER_SIZE = 8192; + + private final String loginUrl; + + PageWriter(String loginUrl) { + this.loginUrl = loginUrl; + } + + static ByteBuffer createSuccessResponsePage() throws IOException { + String successMessage = formatAndEscape("reauth.msauth.code.success"); + String closeMessage = formatAndEscape("reauth.msauth.code.success.close"); + return createPage(successMessage, closeMessage); + } + + ByteBuffer createErrorResponsePage(String errorCode) throws IOException { + String errorMessage = formatAndEscape(getErrorMessage(errorCode)); + String retryMessage = + createLink(formatAndEscape("reauth.msauth.code.retry"), this.loginUrl); + return createPage(errorMessage, retryMessage); + } + + ByteBuffer createHttpErrorResponsePage(HttpStatus error) throws IOException { + String errorMessage = formatAndEscape("reauth.msauth.code.error.http." + error.code); + String retryMessage = + createLink(formatAndEscape("reauth.msauth.code.retry"), this.loginUrl); + return createPage(errorMessage, retryMessage); + } + + private static ByteBuffer createPage(String text1, String text2) throws IOException { + try (InputStream is = AuthenticationCodeServer.class.getResourceAsStream("/reauth/reauth.html")) { + if (is != null) { + String page = IOUtils.toString(is, StandardCharsets.UTF_8); + page = page.replace("$text1", text1).replace("$text2", text2); + ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE); + buffer.put(page.getBytes(StandardCharsets.UTF_8)); + return buffer; + } else { + throw new FileNotFoundException("Resource /reauth/reauth.html is unavailable"); + } + } + } + + private static String createLink(String content, String href) { + return "" + content + ""; + } + + private static String getErrorMessage(String authError) { + String type = switch (authError) { + case "access_denied" -> "cancelled"; + case "server_error", "temporarily_unavailable" -> "server"; + default -> "unknown"; + }; + return "reauth.msauth.code.fail." + type; + } + + private static String formatAndEscape(String key, Object... arguments) { + String text = ReAuth.i18n.apply(key, arguments); + text = text.replaceAll("&", "&"); + text = text.replaceAll("<", "<"); + text = text.replaceAll(">", ">"); + text = text.replaceAll("\"", "&qout;"); + text = text.replaceAll("'", "'"); + return text; + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/server/ResourcesHandler.java b/src/common/java/technicianlp/reauth/authentication/http/server/ResourcesHandler.java new file mode 100644 index 0000000..5d671fa --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/server/ResourcesHandler.java @@ -0,0 +1,65 @@ +package technicianlp.reauth.authentication.http.server; + +import com.sun.net.httpserver.HttpExchange; +import technicianlp.reauth.ReAuth; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.util.Locale; + +final class ResourcesHandler extends Handler { + + ResourcesHandler(PageWriter pageWriter) { + super(pageWriter); + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + String method = exchange.getRequestMethod().toUpperCase(Locale.ROOT); + + Response response; + if ("GET".equals(method) || "HEAD".equals(method)) { + response = handleResourceGet(exchange.getRequestURI().getPath()); + } else if ("POST".equals(method)) { + response = new Response(HttpStatus.Method_Not_Allowed).setHeader("Allow", "GET, HEAD"); + } else { + response = new Response(HttpStatus.Not_Implemented); + } + + this.sendResponse(exchange, response); + } catch (Exception exception) { + ReAuth.log.error("Exception while answering request", exception); + throw exception; + } + } + + private static Response handleResourceGet(String path) throws IOException { + String contentType = null; + String resource = null; + if ("/res/icon.png".equals(path)) { + contentType = "image/png"; + resource = "/reauth/icon.png"; + } else if ("/res/logo.png".equals(path)) { + contentType = "image/png"; + resource = "/reauth/logo.png"; + } + + if (resource != null) { + try (InputStream is = AuthenticationCodeServer.class.getResourceAsStream(resource)) { + if (is != null) { + ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE * 4); + Channels.newChannel(is).read(buffer); + return new Response(HttpStatus.OK).setContent(contentType, buffer); + } else { + throw new FileNotFoundException("Resource " + resource + " is unavailable"); + } + } + } else { + return new Response(HttpStatus.Not_Found); + } + } +} diff --git a/src/common/java/technicianlp/reauth/authentication/http/server/Response.java b/src/common/java/technicianlp/reauth/authentication/http/server/Response.java new file mode 100644 index 0000000..0763090 --- /dev/null +++ b/src/common/java/technicianlp/reauth/authentication/http/server/Response.java @@ -0,0 +1,44 @@ +package technicianlp.reauth.authentication.http.server; + +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; + +final class Response { + + private final HttpStatus httpStatus; + private final Map headers; + private ByteBuffer pageContent; + + Response(HttpStatus httpStatus) { + this.httpStatus = httpStatus; + this.headers = new HashMap<>(); + } + + Response setContent(String contentType, ByteBuffer content) { + this.setHeader("Content-Type", contentType); + this.pageContent = content; + return this; + } + + Response setHeader(String name, String value) { + this.headers.put(name, value); + return this; + } + + HttpStatus getHttpStatus() { + return this.httpStatus; + } + + boolean hasContent() { + return this.pageContent != null; + } + + ByteBuffer getPageContent() { + return this.pageContent; + } + + Map getHeaders() { + return this.headers; + } +} diff --git a/src/common/java/technicianlp/reauth/configuration/ProfileBuilder.java b/src/common/java/technicianlp/reauth/configuration/ProfileBuilder.java new file mode 100644 index 0000000..ea51ae1 --- /dev/null +++ b/src/common/java/technicianlp/reauth/configuration/ProfileBuilder.java @@ -0,0 +1,38 @@ +package technicianlp.reauth.configuration; + +import technicianlp.reauth.ReAuth; +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.authentication.flows.Tokens; +import technicianlp.reauth.crypto.ProfileEncryption; + +import java.util.HashMap; +import java.util.Map; + +public final class ProfileBuilder { + + private final Map profile = new HashMap<>(); + private final ProfileEncryption encryption; + + public ProfileBuilder(SessionData session, ProfileEncryption encryption) { + this.encryption = encryption; + this.profile.put(ProfileConstants.NAME, session.username()); + this.profile.put(ProfileConstants.UUID, session.uuid()); + encryption.saveToProfile(this.profile); + } + + public Profile buildMicrosoft(Tokens tokens) { + this.profile.put(ProfileConstants.PROFILE_TYPE, ProfileConstants.PROFILE_TYPE_MICROSOFT); + this.profile.put(ProfileConstants.XBL_TOKEN, this.encryption.encryptFieldOne(tokens.getXblToken())); + this.profile.put(ProfileConstants.REFRESH_TOKEN, this.encryption.encryptFieldTwo(tokens.getRefreshToken())); + + return ReAuth.profiles.createProfile(this.profile); + } + + public Profile buildMojang(String username, String password) { + this.profile.put(ProfileConstants.PROFILE_TYPE, ProfileConstants.PROFILE_TYPE_MOJANG); + this.profile.put(ProfileConstants.USERNAME, this.encryption.encryptFieldOne(username)); + this.profile.put(ProfileConstants.PASSWORD, this.encryption.encryptFieldTwo(password)); + + return ReAuth.profiles.createProfile(this.profile); + } +} diff --git a/src/common/java/technicianlp/reauth/configuration/ProfileConstants.java b/src/common/java/technicianlp/reauth/configuration/ProfileConstants.java new file mode 100644 index 0000000..5c51d3f --- /dev/null +++ b/src/common/java/technicianlp/reauth/configuration/ProfileConstants.java @@ -0,0 +1,67 @@ +package technicianlp.reauth.configuration; + +import com.google.common.collect.ImmutableList; + +import java.util.List; + +public enum ProfileConstants { + ; + + // profile type + public static final String PROFILE_TYPE = "type"; + public static final String PROFILE_TYPE_NONE = "none"; + public static final String PROFILE_TYPE_MOJANG = "mojang"; + public static final String PROFILE_TYPE_MICROSOFT = "microsoft"; + + // common fields + public static final String NAME = "name"; + public static final String UUID = "uuid"; + + // mojang profiles + public static final String USERNAME = "username"; + public static final String PASSWORD = "password"; + + // microsoft profiles + public static final String REFRESH_TOKEN = "refresh-token"; + public static final String XBL_TOKEN = "xbl-token"; + + // encryption + public static final String KEY = "key"; + public static final String KEY_AUTO = "auto"; + public static final String KEY_NONE = "none"; + public static final String SALT = "salt"; + + /** + * Used to provide consistent ordering for config entries. + */ + private static final List propertyOrder = ImmutableList.of( + PROFILE_TYPE, + NAME, + UUID, + USERNAME, + XBL_TOKEN, + PASSWORD, + REFRESH_TOKEN, + KEY, + SALT); + + static int compareProfileKeys(String key1, String key2) { + if (key1.equals(key2)) { + return 0; + } else { + if (propertyOrder.contains(key1)) { + if (propertyOrder.contains(key2)) { + return Integer.compare(propertyOrder.indexOf(key1), propertyOrder.indexOf(key2)); + } else { + return -1; + } + } else { + if (propertyOrder.contains(key2)) { + return 1; + } else { + return key1.compareTo(key2); + } + } + } + } +} diff --git a/src/common/java/technicianlp/reauth/crypto/Crypto.java b/src/common/java/technicianlp/reauth/crypto/Crypto.java new file mode 100644 index 0000000..9b4c9e1 --- /dev/null +++ b/src/common/java/technicianlp/reauth/crypto/Crypto.java @@ -0,0 +1,39 @@ +package technicianlp.reauth.crypto; + +import technicianlp.reauth.configuration.Profile; +import technicianlp.reauth.configuration.ProfileConstants; + +import java.security.SecureRandom; + +public enum Crypto { + ; + + private static String configPath = ""; + + public static ProfileEncryption getProfileEncryption(Profile profile) { + return switch (profile.getValue(ProfileConstants.KEY)) { + case ProfileConstants.KEY_AUTO -> + new EncryptionAutomatic(configPath, profile.getValue(ProfileConstants.SALT)); + case ProfileConstants.KEY_NONE -> new EncryptionNone(); + default -> throw new IllegalArgumentException("Unknown Encryption Type"); + }; + } + + public static ProfileEncryption newEncryption() { + return new EncryptionAutomatic(configPath); + } + + public static byte[] randomBytes(int length) { + byte[] salt = new byte[length]; + new SecureRandom().nextBytes(salt); + return salt; + } + + public static PkceChallenge createPkceChallenge() { + return new PkceChallenge(); + } + + public static void updateConfigPath(String configPath) { + Crypto.configPath = configPath; + } +} diff --git a/src/common/java/technicianlp/reauth/crypto/CryptoException.java b/src/common/java/technicianlp/reauth/crypto/CryptoException.java new file mode 100644 index 0000000..809ad5b --- /dev/null +++ b/src/common/java/technicianlp/reauth/crypto/CryptoException.java @@ -0,0 +1,8 @@ +package technicianlp.reauth.crypto; + +public final class CryptoException extends RuntimeException { + + public CryptoException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/common/java/technicianlp/reauth/crypto/EncryptionAutomatic.java b/src/common/java/technicianlp/reauth/crypto/EncryptionAutomatic.java new file mode 100644 index 0000000..7a5e01d --- /dev/null +++ b/src/common/java/technicianlp/reauth/crypto/EncryptionAutomatic.java @@ -0,0 +1,113 @@ +package technicianlp.reauth.crypto; + +import technicianlp.reauth.configuration.ProfileConstants; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.InvalidKeySpecException; +import java.util.Base64; +import java.util.Map; + +final class EncryptionAutomatic implements ProfileEncryption { + + private static final int PBE_ROUNDS = 250_000; + private static final int IV1_OFFSET = 32; + private static final int IV2_OFFSET = 48; + + private final byte[] keyData; + + private final String path; + private final byte[] salt; + + EncryptionAutomatic(String path) { + this(path, Crypto.randomBytes(16)); + } + + EncryptionAutomatic(String path, String salt) throws CryptoException { + this(path, Base64.getDecoder().decode(salt)); + } + + EncryptionAutomatic(String path, byte[] salt) throws CryptoException { + try { + SecretKeyFactory pbkdf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512"); + SecretKey key = pbkdf.generateSecret(new PBEKeySpec(path.toCharArray(), salt, PBE_ROUNDS, 512)); + this.keyData = key.getEncoded(); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new CryptoException("Failed to derive encryption key", e); + } + this.path = path; + this.salt = salt; + } + + @Override + public String decryptFieldOne(String encrypted) throws CryptoException { + return this.decrypt(encrypted, IV1_OFFSET); + } + + @Override + public String decryptFieldTwo(String encrypted) throws CryptoException { + return this.decrypt(encrypted, IV2_OFFSET); + } + + private String decrypt(String encrypted, int ivOffset) throws CryptoException { + try { + byte[] raw = Base64.getDecoder().decode(encrypted); + byte[] dec = crypt(raw, Cipher.DECRYPT_MODE, this.keyData, ivOffset); + return new String(dec, StandardCharsets.UTF_8); + } catch (GeneralSecurityException e) { + throw new CryptoException("Decryption failed", e); + } + } + + @Override + public String encryptFieldOne(String value) throws CryptoException { + return this.encrypt(value, IV1_OFFSET); + } + + @Override + public String encryptFieldTwo(String value) throws CryptoException { + return this.encrypt(value, IV2_OFFSET); + } + + private String encrypt(String value, int ivOffset) throws CryptoException { + try { + byte[] raw = value.getBytes(StandardCharsets.UTF_8); + byte[] enc = crypt(raw, Cipher.ENCRYPT_MODE, this.keyData, ivOffset); + return Base64.getEncoder().encodeToString(enc); + } catch (GeneralSecurityException e) { + throw new CryptoException("Encryption failed", e); + } + } + + /** + * Encrypt or decrypt the supplied data with the given Key and IV + */ + private static byte[] crypt(byte[] data, int mode, byte[] keyData, int ivOffset) throws GeneralSecurityException { + Key secretKey = new SecretKeySpec(keyData, 0, 32, "AES"); + AlgorithmParameterSpec ivParameterSpec = new IvParameterSpec(keyData, ivOffset, 16); + + Cipher aes = Cipher.getInstance("AES/CBC/PKCS5Padding"); + aes.init(mode, secretKey, ivParameterSpec); + return aes.doFinal(data); + } + + @Override + public void saveToProfile(Map profile) { + profile.put(ProfileConstants.KEY, ProfileConstants.KEY_AUTO); + profile.put(ProfileConstants.SALT, Base64.getEncoder().encodeToString(this.salt)); + } + + @Override + public ProfileEncryption randomizedCopy() { + return new EncryptionAutomatic(this.path); + } +} diff --git a/src/common/java/technicianlp/reauth/crypto/EncryptionNone.java b/src/common/java/technicianlp/reauth/crypto/EncryptionNone.java new file mode 100644 index 0000000..c71b7ac --- /dev/null +++ b/src/common/java/technicianlp/reauth/crypto/EncryptionNone.java @@ -0,0 +1,38 @@ +package technicianlp.reauth.crypto; + +import technicianlp.reauth.configuration.ProfileConstants; + +import java.util.Map; + +final class EncryptionNone implements ProfileEncryption { + + @Override + public String decryptFieldOne(String encrypted) { + return encrypted; + } + + @Override + public String decryptFieldTwo(String encrypted) { + return encrypted; + } + + @Override + public String encryptFieldOne(String value) { + return value; + } + + @Override + public String encryptFieldTwo(String value) { + return value; + } + + @Override + public void saveToProfile(Map profile) { + profile.put(ProfileConstants.KEY, ProfileConstants.KEY_NONE); + } + + @Override + public ProfileEncryption randomizedCopy() { + return new EncryptionNone(); + } +} diff --git a/src/common/java/technicianlp/reauth/crypto/PkceChallenge.java b/src/common/java/technicianlp/reauth/crypto/PkceChallenge.java new file mode 100644 index 0000000..43c76f3 --- /dev/null +++ b/src/common/java/technicianlp/reauth/crypto/PkceChallenge.java @@ -0,0 +1,40 @@ +package technicianlp.reauth.crypto; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Utility for generating a PKCE Verifier and Challenge as specified by RFC7636 + */ +public final class PkceChallenge { + + final String challenge; + final String verifier; + + PkceChallenge() { + Base64.Encoder base64url = Base64.getUrlEncoder().withoutPadding(); + + byte[] verifierCode = new byte[32]; + new SecureRandom().nextBytes(verifierCode); + this.verifier = base64url.encodeToString(verifierCode); + + try { + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + byte[] challengeCode = sha256.digest(this.verifier.getBytes(StandardCharsets.US_ASCII)); + this.challenge = base64url.encodeToString(challengeCode); + } catch (NoSuchAlgorithmException e) { + throw new CryptoException("SHA-256 unavailable", e); + } + } + + public String getChallenge() { + return this.challenge; + } + + public String getVerifier() { + return this.verifier; + } +} diff --git a/src/common/java/technicianlp/reauth/crypto/ProfileEncryption.java b/src/common/java/technicianlp/reauth/crypto/ProfileEncryption.java new file mode 100644 index 0000000..b88a4bc --- /dev/null +++ b/src/common/java/technicianlp/reauth/crypto/ProfileEncryption.java @@ -0,0 +1,21 @@ +package technicianlp.reauth.crypto; + +import java.util.Map; + +public interface ProfileEncryption { + + String decryptFieldOne(String encrypted) throws CryptoException; + + String decryptFieldTwo(String encrypted) throws CryptoException; + + String encryptFieldOne(String value) throws CryptoException; + + String encryptFieldTwo(String value) throws CryptoException; + + void saveToProfile(Map profile); + + /** + * creates a new Instance of this {@link ProfileEncryption} with new, random parameters + */ + ProfileEncryption randomizedCopy(); +} diff --git a/src/common/java/technicianlp/reauth/mojangfix/CertWorkaround.java b/src/common/java/technicianlp/reauth/mojangfix/CertWorkaround.java new file mode 100644 index 0000000..19d4919 --- /dev/null +++ b/src/common/java/technicianlp/reauth/mojangfix/CertWorkaround.java @@ -0,0 +1,207 @@ +package technicianlp.reauth.mojangfix; + +import technicianlp.reauth.ReAuth; + +import javax.net.ssl.*; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.Socket; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.*; + +public enum CertWorkaround { + ; + + private static final String MICROSOFT2017 = "microsoftrsarootcertificateauthority2017"; + private static final String AMAZON1 = "amazonrootca1"; + private static final String DIGICERT2 = "digicertglobalrootg2"; + + private static SSLSocketFactory socketFactory; + + public static SSLSocketFactory getSocketFactory() { + return socketFactory; + } + + /** + * The default truststore is missing some CA-Certificates required during authentication with Microsoft/XBox/Mojang + * because Mojang for some insane reason ships the 7 years old Java 8 Update 51 (July 14, 2015). + *

+ * The following Certificates are installed if they are missing: - Microsoft RSA Root Certificate Authority 2017 - + * DigiCert Global Root G2 - Amazon Root CA 1 + */ + static void checkCertificates() { + try { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate microsoft2017 = loadCertificate(cf, MICROSOFT2017); + X509Certificate amazon1 = loadCertificate(cf, AMAZON1); + X509Certificate digicert2 = loadCertificate(cf, DIGICERT2); + + TrustManagerFactory defaultTrust = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + defaultTrust.init((KeyStore) null); + + List trustedCerts = getTrustedCerts(defaultTrust); + Map missingCerts = new HashMap<>(); + if (!trustedCerts.contains(microsoft2017)) { + missingCerts.put(MICROSOFT2017, microsoft2017); + } + if (!trustedCerts.contains(amazon1)) { + missingCerts.put(AMAZON1, amazon1); + } + if (!trustedCerts.contains(digicert2)) { + missingCerts.put(DIGICERT2, digicert2); + } + + if (missingCerts.isEmpty()) { + // no additional certificates required + return; + } else { + ReAuth.log.warn("Some Certificates required for authentication are untrusted by default"); + } + + X509ExtendedTrustManager defaultTrustManager = findX509ExtendedTrustManager(defaultTrust); + X509ExtendedTrustManager missingTrustManager = + findX509ExtendedTrustManager(createTrustFactory(missingCerts)); + X509ExtendedTrustManager combinedTrustManager = + new CombinedX509ExtendedTrustManager(defaultTrustManager, missingTrustManager); + + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new X509ExtendedTrustManager[]{combinedTrustManager}, null); + + socketFactory = context.getSocketFactory(); + ReAuth.log.info("Successfully built SSLSocketFactory with required Certificates"); + } catch (GeneralSecurityException | IOException e) { + throw new RuntimeException(e); + } + } + + private static X509Certificate loadCertificate(CertificateFactory certFactory, String name) throws + CertificateException, IOException { + try (InputStream is = CertWorkaround.class.getResourceAsStream("/reauth/certs/" + name + ".pem")) { + if (is != null) { + return (X509Certificate) certFactory.generateCertificate(is); + } else { + throw new FileNotFoundException("Certificate " + name + " is unavailable"); + } + } + } + + private static List getTrustedCerts(TrustManagerFactory trustManagerFactory) { + X509ExtendedTrustManager trustManager = findX509ExtendedTrustManager(trustManagerFactory); + if (trustManager != null) { + return new ArrayList<>(Arrays.asList(trustManager.getAcceptedIssuers())); + } else { + return new ArrayList<>(); + } + } + + private static TrustManagerFactory createTrustFactory(Map certificates) throws + GeneralSecurityException, IOException { + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null); + for (Map.Entry certificate : certificates.entrySet()) { + ReAuth.log.info("Adding Certificate {} to trust", certificate.getKey()); + ks.setCertificateEntry(certificate.getKey(), certificate.getValue()); + } + TrustManagerFactory trustFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustFactory.init(ks); + return trustFactory; + } + + private static X509ExtendedTrustManager findX509ExtendedTrustManager(TrustManagerFactory trustManagerFactory) { + return Arrays.stream(trustManagerFactory.getTrustManagers()) + .filter(X509ExtendedTrustManager.class::isInstance) + .map(X509ExtendedTrustManager.class::cast) + .findFirst() + .orElse(null); + } + + /** + * required in order to combine multiple TrustManagers. The default Implementation of {@link SSLContextSpi} only + * considers the first {@link X509TrustManager} + */ + private static final class CombinedX509ExtendedTrustManager extends X509ExtendedTrustManager { + + private final List trustManagers; + + private CombinedX509ExtendedTrustManager(X509ExtendedTrustManager... trustManagers) { + this.trustManagers = new ArrayList<>(Arrays.asList(trustManagers)); + if (this.trustManagers.isEmpty()) { + throw new IllegalArgumentException("At least one X509ExtendedTrustManager is required"); + } + if (this.trustManagers.contains(null)) { + throw new IllegalArgumentException("X509ExtendedTrustManager cannot be null"); + } + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) throws + CertificateException { + this.check(tm -> tm.checkClientTrusted(chain, authType, socket)); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) throws + CertificateException { + this.check(tm -> tm.checkServerTrusted(chain, authType, socket)); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) throws + CertificateException { + this.check(tm -> tm.checkClientTrusted(chain, authType, engine)); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) throws + CertificateException { + this.check(tm -> tm.checkServerTrusted(chain, authType, engine)); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + this.check(tm -> tm.checkClientTrusted(chain, authType)); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + this.check(tm -> tm.checkServerTrusted(chain, authType)); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return this.trustManagers.stream().map(X509TrustManager::getAcceptedIssuers).flatMap(Arrays::stream) + .toArray(X509Certificate[]::new); + } + + /** + * checks if any of the trustManagers accepts the supplied operation. + * + * @throws CertificateException if all trustManagers refuse the operation + */ + private void check(CertificateCheckConsumer checkFunction) throws CertificateException { + Deque exceptions = new LinkedList<>(); + for (X509ExtendedTrustManager trustManager : this.trustManagers) { + try { + checkFunction.check(trustManager); + return; // accepted by TrustManager + } catch (CertificateException e) { + exceptions.add(e); + } + } + CertificateException last = exceptions.removeLast(); + exceptions.forEach(last::addSuppressed); + throw last; + } + } + + @FunctionalInterface + private interface CertificateCheckConsumer { + void check(X509ExtendedTrustManager trustManager) throws CertificateException; + } +} diff --git a/src/common/java/technicianlp/reauth/mojangfix/JceWorkaround.java b/src/common/java/technicianlp/reauth/mojangfix/JceWorkaround.java new file mode 100644 index 0000000..0a15da9 --- /dev/null +++ b/src/common/java/technicianlp/reauth/mojangfix/JceWorkaround.java @@ -0,0 +1,78 @@ +package technicianlp.reauth.mojangfix; + +import technicianlp.reauth.ReAuth; +import technicianlp.reauth.crypto.CryptoException; +import technicianlp.reauth.util.ReflectionUtils; + +import javax.crypto.Cipher; +import java.lang.reflect.Field; +import java.security.NoSuchAlgorithmException; +import java.security.PermissionCollection; +import java.util.Map; + +enum JceWorkaround { + ; + + /** + * check if CryptoAllPermission is in effect and try to remove Jce restrictions otherwise + */ + static void ensureUnlimitedCryptography() { + try { + if (Cipher.getMaxAllowedKeyLength("AES") != Integer.MAX_VALUE) { + ReAuth.log.warn("Cryptography is restricted in this Java installation"); + if (!MojangJavaFix.java8) { + ReAuth.log.warn("Cryptography is likely deliberately restricted!"); + } + removeCryptographyRestrictions(); + if (Cipher.getMaxAllowedKeyLength("AES") == Integer.MAX_VALUE) { + ReAuth.log.info("Cryptography restriction removed successfully"); + } else { + ReAuth.log.error("Failed to remove cryptography restriction"); + } + } + } catch (NoSuchAlgorithmException e) { + throw new CryptoException("AES unavailable", e); + } + } + + /** + * Java had for legal reasons limited the allowed strength of cryptographic algorithms. Historically to disable this + * restriction the so-called "Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files" have + * to be installed within the JRE directory. Since update 151 (October 17, 2017) these restrictions can be disabled + * programmatically and have since been disabled by default in update 161 (January 16, 2018). + *

+ * Since Mojang for some insane reason ships the 7 years old update 51 (July 14, 2015), installation of the policy + * files would be necessary. Since installation of those files cannot be required of the user, a workaround has been + * found in https://stackoverflow.com/questions/1179672 + * and is used to disable this restriction at runtime: + *

+ * JceSecurity.isRestricted = false; JceSecurity.defaultPolicy.perms.clear(); + * JceSecurity.defaultPolicy.add(CryptoAllPermission.INSTANCE); + *

+ * The alternative to this workaround would have been to drop the AES key-length from 256 bits to 128 bits. + */ + private static void removeCryptographyRestrictions() { + try { + Class jceSecurity = Class.forName("javax.crypto.JceSecurity"); + Field isRestricted = ReflectionUtils.findField(jceSecurity, "isRestricted"); + ReflectionUtils.unlockFinalField(isRestricted); + Field defaultPolicy = ReflectionUtils.findField(jceSecurity, "defaultPolicy"); + + Class cryptoPermissions = Class.forName("javax.crypto.CryptoPermissions"); + Field perms = ReflectionUtils.findField(cryptoPermissions, "perms"); + + Class cryptoAllPermission = Class.forName("javax.crypto.CryptoAllPermission"); + Field instance = ReflectionUtils.findField(cryptoAllPermission, "INSTANCE"); + + + ReflectionUtils.setField(isRestricted, null, false); + + PermissionCollection permissionCollection = ReflectionUtils.getField(defaultPolicy, null); + ((Map) ReflectionUtils.getField(perms, permissionCollection)).clear(); + + permissionCollection.add(ReflectionUtils.getField(instance, null)); + } catch (Exception e) { + ReAuth.log.error("Exception removing cryptography restrictions", e); + } + } +} diff --git a/src/common/java/technicianlp/reauth/mojangfix/MojangJavaFix.java b/src/common/java/technicianlp/reauth/mojangfix/MojangJavaFix.java new file mode 100644 index 0000000..398318b --- /dev/null +++ b/src/common/java/technicianlp/reauth/mojangfix/MojangJavaFix.java @@ -0,0 +1,36 @@ +package technicianlp.reauth.mojangfix; + +import technicianlp.reauth.ReAuth; + +import java.time.LocalDate; +import java.time.Month; +import java.time.Period; +import java.time.ZoneOffset; + +public enum MojangJavaFix { + ; + + public static final boolean mojangJava; + public static final boolean java8; + + static { + String javaVersion = System.getProperty("java.version"); + mojangJava = "1.8.0_51".equals(javaVersion); + java8 = javaVersion.startsWith("1.8"); + } + + public static void fixMojangJava() { + if (mojangJava) { + Period age = Period.between(LocalDate.of(2015, Month.JULY, 14), LocalDate.now(ZoneOffset.UTC)); + ReAuth.log.warn("+------------------------------------------------------------------+"); + ReAuth.log.warn("| Please complain to Mojang for shipping an ancient Java version |"); + ReAuth.log.warn("| Java 8 Update 51 is {} years {} months and {} days old |", age.getYears(), + age.getMonths(), age.getDays()); + ReAuth.log.warn("| Updating would avoid several issues and vulnerabilities |"); + ReAuth.log.warn("+------------------------------------------------------------------+"); + } + JceWorkaround.ensureUnlimitedCryptography(); + CertWorkaround.checkCertificates(); + } + +} diff --git a/src/common/java/technicianlp/reauth/session/SessionChecker.java b/src/common/java/technicianlp/reauth/session/SessionChecker.java new file mode 100644 index 0000000..6459785 --- /dev/null +++ b/src/common/java/technicianlp/reauth/session/SessionChecker.java @@ -0,0 +1,59 @@ +package technicianlp.reauth.session; + +import technicianlp.reauth.ReAuth; +import technicianlp.reauth.authentication.YggdrasilAPI; +import technicianlp.reauth.authentication.http.UnreachableServiceException; + +import java.util.concurrent.CompletableFuture; + +public enum SessionChecker { + ; + + /** + * Time for which the Validity gets cached (5 Minutes) + */ + private static final long cacheTime = 5 * 1000 * 60L; + + /** + * Current cached Session Validity + */ + private static SessionStatus status = SessionStatus.UNKNOWN; + private static long lastCheck; + + /** + * Get the cached Validity Status of the accessToken Re-Validation is done if the cache expires + */ + public static SessionStatus getSessionStatus(String token, String uuid) { + if (lastCheck + cacheTime < System.currentTimeMillis()) { + status = SessionStatus.UNKNOWN; + } + + if (status == SessionStatus.UNKNOWN) { + status = SessionStatus.REFRESHING; + lastCheck = System.currentTimeMillis(); + + CompletableFuture tokenFuture = CompletableFuture.completedFuture(token); + CompletableFuture uuidFuture = CompletableFuture.completedFuture(uuid); + tokenFuture.thenCombineAsync(uuidFuture, SessionChecker::getSessionStatus0, ReAuth.executor) + .thenAccept(SessionChecker::setStatus); + } + return status; + } + + public static void invalidate() { + status = SessionStatus.UNKNOWN; + } + + private static SessionStatus getSessionStatus0(String accessToken, String uuid) { + try { + return YggdrasilAPI.validate(accessToken, uuid) ? SessionStatus.VALID : SessionStatus.INVALID; + } catch (UnreachableServiceException e) { + ReAuth.log.error("Failed to check session validity", e); + return SessionStatus.ERROR; + } + } + + private static void setStatus(SessionStatus newStatus) { + status = newStatus; + } +} diff --git a/src/common/java/technicianlp/reauth/session/SessionStatus.java b/src/common/java/technicianlp/reauth/session/SessionStatus.java new file mode 100644 index 0000000..b81e739 --- /dev/null +++ b/src/common/java/technicianlp/reauth/session/SessionStatus.java @@ -0,0 +1,19 @@ +package technicianlp.reauth.session; + +public enum SessionStatus { + VALID("valid"), + INVALID("invalid"), + UNKNOWN("unknown"), + REFRESHING("refreshing"), + ERROR("error"); + + private final String translationKey; + + SessionStatus(String translationKey) { + this.translationKey = "reauth.status." + translationKey; + } + + public final String getTranslationKey() { + return this.translationKey; + } +} diff --git a/src/common/java/technicianlp/reauth/util/ReflectionUtils.java b/src/common/java/technicianlp/reauth/util/ReflectionUtils.java new file mode 100644 index 0000000..5a920d8 --- /dev/null +++ b/src/common/java/technicianlp/reauth/util/ReflectionUtils.java @@ -0,0 +1,54 @@ +package technicianlp.reauth.util; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; + +public enum ReflectionUtils { + ; + + private static Field findFieldInternal(Class clz, String name) throws NoSuchFieldException { + Field field = clz.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + public static Field findField(Class clz, String name) { + try { + return findFieldInternal(clz, name); + } catch (NoSuchFieldException exception) { + throw new UncheckedReflectiveOperationException("Unable to find Field: " + name, exception); + } + } + + public static void unlockFinalField(Field field) { + try { + Field fieldModifiers = findField(Field.class, "modifiers"); + fieldModifiers.setInt(field, field.getModifiers() & ~Modifier.FINAL); + } catch (IllegalAccessException exception) { + throw new UncheckedReflectiveOperationException("Unable to unlock final field", exception); + } + } + + public static void setField(Field field, Object target, Object value) { + try { + field.set(target, value); + } catch (IllegalAccessException exception) { + throw new UncheckedReflectiveOperationException("Failed Reflective set", exception); + } + } + + public static T getField(Field field, Object target) { + try { + //noinspection unchecked + return (T) field.get(target); + } catch (IllegalAccessException exception) { + throw new UncheckedReflectiveOperationException("Failed Reflective get", exception); + } + } + + public static class UncheckedReflectiveOperationException extends RuntimeException { + public UncheckedReflectiveOperationException(String message, ReflectiveOperationException cause) { + super(message, cause); + } + } +} diff --git a/src/common/resources/reauth/certs/amazonrootca1.pem b/src/common/resources/reauth/certs/amazonrootca1.pem new file mode 100644 index 0000000..a6f3e92 --- /dev/null +++ b/src/common/resources/reauth/certs/amazonrootca1.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj +ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM +9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw +IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 +VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L +93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm +jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA +A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI +U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs +N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv +o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU +5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy +rqXRfboQnoZsG4q5WTP468SQvvG5 +-----END CERTIFICATE----- diff --git a/src/common/resources/reauth/certs/digicertglobalrootg2.pem b/src/common/resources/reauth/certs/digicertglobalrootg2.pem new file mode 100644 index 0000000..798e002 --- /dev/null +++ b/src/common/resources/reauth/certs/digicertglobalrootg2.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH +MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI +2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx +1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ +q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz +tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ +vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV +5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY +1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4 +NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG +Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91 +8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe +pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl +MrY= +-----END CERTIFICATE----- diff --git a/src/common/resources/reauth/certs/microsoftrsarootcertificateauthority2017.pem b/src/common/resources/reauth/certs/microsoftrsarootcertificateauthority2017.pem new file mode 100644 index 0000000..3bdfb23 --- /dev/null +++ b/src/common/resources/reauth/certs/microsoftrsarootcertificateauthority2017.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBl +MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYw +NAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5 +IDIwMTcwHhcNMTkxMjE4MjI1MTIyWhcNNDIwNzE4MjMwMDIzWjBlMQswCQYDVQQG +EwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1N +aWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZ +Nt9GkMml7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/xztokLaCLeX0 +ZdDMbRnMlfl7rEqUrQ7eS0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1 +HLUX6NZFndiyJrDKxHBKrmCk3bPZ7Pw71VdyvD/IybLeS2v4I2wDwAW9lcfNcztm +gGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+dkC0zVJhUXAoP8XFWvLJ +jEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49FyGcohJUc +aDDv70ngNXtk55iwlNpNhTs+VcQor1fznhPbRiefHqJeRIOkpcrVE7NLP8TjwuaG +YaRSMLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6 +W6IYZVcSn2i51BVrlMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4K +UGsTuqwPN1q3ErWQgR5WrlcihtnJ0tHXUeOrO8ZV/R4O03QK0dqq6mm4lyiPSMQH ++FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJClTUFLkqqNfs+avNJVgyeY+Q +W5g5xAgGwax/Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tNsiMwEAYJKwYBBAGC +NxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZC +LgLNFgVZJ8og6Lq46BrsTaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OC +gMNPOsduET/m4xaRhPtthH80dK2Jp86519efhGSSvpWhrQlTM93uCupKUY5vVau6 +tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk+ONVFT24bcMKpBLBaYVu32TxU5nh +SnUgnZUP5NbcA/FZGOhHibJXWpS2qdgXKxdJ5XbLwVaZOjex/2kskZGT4d9Mozd2 +TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDyAmH3 +pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGR +xpl/j8nWZjEgQRCHLQzWwa80mMpkg/sTV9HB8Dx6jKXB/ZUhoHHBk2dxEuqPiApp +GWSZI1b7rCoucL5mxAyE7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9 +dOLITzNy4ZQ5bb0Sr74MTnB8G2+NszKTc0QWbej09+CVgI+WXTik9KveCjCHk9hN +AHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D5KbvtwEwXlGjefVwaaZB +RA+GsCyRxj3qrg+E +-----END CERTIFICATE----- diff --git a/src/common/resources/reauth/icon.png b/src/common/resources/reauth/icon.png new file mode 100644 index 0000000..68742fb Binary files /dev/null and b/src/common/resources/reauth/icon.png differ diff --git a/src/common/resources/reauth/logo.png b/src/common/resources/reauth/logo.png new file mode 100644 index 0000000..58f04ab Binary files /dev/null and b/src/common/resources/reauth/logo.png differ diff --git a/src/common/resources/reauth/reauth.html b/src/common/resources/reauth/reauth.html new file mode 100644 index 0000000..48aee85 --- /dev/null +++ b/src/common/resources/reauth/reauth.html @@ -0,0 +1,57 @@ + + + + ReAuth + + + + +ReAuth +

$text1

+

$text2

+ + diff --git a/src/main/java/technicianlp/reauth/AuthHelper.java b/src/main/java/technicianlp/reauth/AuthHelper.java deleted file mode 100644 index bc07ceb..0000000 --- a/src/main/java/technicianlp/reauth/AuthHelper.java +++ /dev/null @@ -1,205 +0,0 @@ -package technicianlp.reauth; - -import com.google.common.base.Preconditions; -import com.mojang.authlib.Agent; -import com.mojang.authlib.exceptions.AuthenticationException; -import com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService; -import com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication; -import com.mojang.util.UUIDTypeAdapter; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.util.Session; -import technicianlp.reauth.mixin.MinecraftClientMixin; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.net.Proxy; -import java.nio.charset.StandardCharsets; -import java.util.UUID; -import java.util.function.BiFunction; -import java.util.regex.Pattern; - -public final class AuthHelper { - - /** - * Time for which the Validity gets cached (5 Minutes) - */ - private static final long cacheTime = 5 * 1000 * 60L; - - /** - * Reflective YggdrasilUserAuthentication#setAccessToken - */ - private static final Field accessToken; - /** - * Reflective YggdrasilUserAuthentication#checkTokenValidity - */ - private static final Method checkTokenValidity; - /** - * Reflective YggdrasilUserAuthentication#logInWithPassword - */ - private static final Method logInWithPassword; - /** - * Reflective {@link YggdrasilUserAuthentication#YggdrasilUserAuthentication} - */ - private static final Constructor authConstructor; - private static final BiFunction authParameterFactory; - - static { - Class clz = YggdrasilUserAuthentication.class; - accessToken = ReflectionHelper.findField(clz, "accessToken"); - Preconditions.checkNotNull(accessToken, "Reflection failed: accessToken"); - checkTokenValidity = ReflectionHelper.findMethod(clz, "checkTokenValidity"); - Preconditions.checkNotNull(checkTokenValidity, "Reflection failed: checkTokenValidity"); - logInWithPassword = ReflectionHelper.findMethod(clz, "logInWithPassword"); - Preconditions.checkNotNull(logInWithPassword, "Reflection failed: logInWithPassword"); - - // Constructor changed between 1.16.3 and 1.16.4: clientToken needs to be passed into the YUA - Constructor constructor; - constructor = ReflectionHelper.findConstructor(clz, YggdrasilAuthenticationService.class, String.class, Agent.class); - if (constructor != null) { - authParameterFactory = (authService, clientToken) -> new Object[]{authService, clientToken, Agent.MINECRAFT}; - } else { - constructor = ReflectionHelper.findConstructor(clz, YggdrasilAuthenticationService.class, Agent.class); - authParameterFactory = (authService, clientToken) -> new Object[]{authService, Agent.MINECRAFT}; - } - Preconditions.checkNotNull(constructor, "Reflection failed: "); - authConstructor = constructor; - } - - /** - * Current cached Session Validity - */ - private SessionStatus status = SessionStatus.UNKNOWN; - private long lastCheck = 0; - - /** - * Two Authentication Service are required as a - * Validation-Request may not have a clientToken, - * yet a Login-Request requires it. - */ - private final YggdrasilUserAuthentication checkAuth; - private final YggdrasilUserAuthentication loginAuth; - - /** - * Pattern for valid Minecraft Names according to Wiki - */ - private final Pattern namePattern = Pattern.compile("[A-Za-z0-9_]{2,16}"); - - public AuthHelper() { - Proxy proxy = MinecraftClient.getInstance().getNetworkProxy(); - String clientId = UUID.randomUUID().toString(); - Object[] checkAuthParams = authParameterFactory.apply(new YggdrasilAuthenticationService(proxy, (String) null), null); - checkAuth = ReflectionHelper.callConstructor(authConstructor, checkAuthParams); - Object[] loginAuthParams = authParameterFactory.apply(new YggdrasilAuthenticationService(proxy, clientId), clientId); - loginAuth = ReflectionHelper.callConstructor(authConstructor, loginAuthParams); - } - - /** - * Get the cached Validity Status of the accessToken - * Re-Validation is done if the cache expires or it is forced by the Parameter of the same name - */ - public SessionStatus getSessionStatus(boolean force) { - if (force || lastCheck + cacheTime < System.currentTimeMillis()) - status = SessionStatus.UNKNOWN; - - if (status == SessionStatus.UNKNOWN) { - status = SessionStatus.REFRESHING; - lastCheck = System.currentTimeMillis(); - - Thread t = new Thread(this::updateSessionStatus, "ReAuth Session Validator"); - t.setDaemon(true); - t.start(); - } - return status; - } - - /** - * Uses the Validate Endpoint to check the current Tokens validity and updates the cache accordingly - */ - private void updateSessionStatus() { - ReflectionHelper.setField(accessToken, checkAuth, getSession().getAccessToken()); - boolean valid = ReflectionHelper.callMethod(checkTokenValidity, checkAuth); - status = valid ? SessionStatus.VALID : SessionStatus.INVALID; - lastCheck = System.currentTimeMillis(); - } - - /** - * Login with the Supplied Username and Password - * Password is saved to config if {@code savePassword} is true - **/ - public void login(String user, char[] password, boolean savePassword) throws AuthenticationException { - login(user, new String(password), savePassword); - } - - public void login(String user, String password, boolean savePassword) throws AuthenticationException { - loginAuth.setUsername(user); - loginAuth.setPassword(password); - try { - try { - ReflectionHelper.callMethod(logInWithPassword, loginAuth); - } catch (ReflectionHelper.UncheckedInvocationTargetException exception) { - Throwable parent = exception.getCause(); - if (parent instanceof AuthenticationException) - throw (AuthenticationException) parent; - ReAuth.log.error("LogInWithPassword has thrown unexpected exception:", parent); - } - - String username = loginAuth.getSelectedProfile().getName(); - String uuid = UUIDTypeAdapter.fromUUID(loginAuth.getSelectedProfile().getId()); - String access = loginAuth.getAuthenticatedToken(); - String type = loginAuth.getUserType().getName(); - - Session session = new Session(username, uuid, access, type); -// session.setProperties(loginAuth.getUserProperties()); - - loginAuth.logOut(); - - setSession(session); - - ReAuth.config.setCredentials(username, user, savePassword ? password : ""); - } finally { - loginAuth.logOut(); - } - } - - /** - * Sets the Players Offline Username - * UUID is generated from the supplied Username - */ - public void offline(String username) { - UUID uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + username).getBytes(StandardCharsets.UTF_8)); - setSession(new Session(username, uuid.toString(), "invalid", "legacy")); - ReAuth.log.info("Offline Username set!"); - ReAuth.config.setCredentials(username, "", ""); - } - - private Session getSession() { - return MinecraftClient.getInstance().getSession(); - } - - private void setSession(Session s) { - ((MinecraftClientMixin) MinecraftClient.getInstance()).reauthSetSession(s); - status = SessionStatus.UNKNOWN; - } - - public boolean isValidName(String username) { - return namePattern.matcher(username).matches(); - } - - public enum SessionStatus { - VALID("valid"), - UNKNOWN("unknown"), - REFRESHING("unknown"), - INVALID("invalid"); - - private final String translationKey; - - SessionStatus(String translationKey) { - this.translationKey = "reauth.status." + translationKey; - } - - public String getTranslationKey() { - return translationKey; - } - } -} diff --git a/src/main/java/technicianlp/reauth/ConfigWrapper.java b/src/main/java/technicianlp/reauth/ConfigWrapper.java deleted file mode 100644 index 5b4936a..0000000 --- a/src/main/java/technicianlp/reauth/ConfigWrapper.java +++ /dev/null @@ -1,171 +0,0 @@ -package technicianlp.reauth; - -import me.sargunvohra.mcmods.autoconfig1u.AutoConfig; -import me.sargunvohra.mcmods.autoconfig1u.ConfigHolder; -import me.sargunvohra.mcmods.autoconfig1u.ConfigManager; -import me.sargunvohra.mcmods.autoconfig1u.serializer.Toml4jConfigSerializer; -import net.fabricmc.loader.api.FabricLoader; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.GeneralSecurityException; -import java.security.SecureRandom; -import java.util.Base64; - -public final class ConfigWrapper { - - public static final String CONFIG_NAME = "../reauth"; - - static void registerConfig() { - AutoConfig.register(Configuration.class, Toml4jConfigSerializer::new); - } - - private Configuration config; - private Crypto crypto; - - public ConfigWrapper() { - if (!Boolean.getBoolean("mods.reauth.disableCrypto")) { - try { - this.crypto = new Crypto(); - } catch (GeneralSecurityException e) { - ReAuth.log.error("Unable to locate cryptographic algorithms. Credentials cannot be saved", e); - } - } else { - ReAuth.log.error("Crypto disabled by commandline"); - } - } - - public void onLoad(Configuration config0) { - this.config = config0; - if (config.version == 1) { - convertConfigV1(); - return; - } - - Configuration.Credentials credentials = config.credentials; - byte[] salt = null; - boolean saltLoaded = false; - String saltRaw = credentials.salt; - if (!saltRaw.isEmpty()) { - try { - salt = Base64.getDecoder().decode(saltRaw); - if (salt.length == 16) - saltLoaded = true; - else - ReAuth.log.error("Salt corrupted, saved credentials cannot be recovered"); - } catch (IllegalArgumentException e) { - ReAuth.log.error("Could not load salt, saved credentials cannot be recovered", e); - } - } - if (!saltLoaded) { - salt = createSalt(); - credentials.salt = Base64.getEncoder().encodeToString(salt); - } - if (crypto != null) - crypto.setup(getPath(), salt); - } - - /** - * Migrate Version 1 > 2 - */ - private void convertConfigV1() { - config.version = 2; - byte[] salt = createSalt(); - Configuration.Credentials credentials = config.credentials; - credentials.salt = Base64.getEncoder().encodeToString(salt); - if (crypto != null) - crypto.setup(getPath(), salt); - setCredentials("", credentials.username, credentials.password); - } - - /** - * Path is used as the Key for encryption - * Resolve symlinks so as to allow the resulting hash to be the same across symlinks - * Fall back to "local" path if that lookup fails (somehow) - */ - private String getPath() { - Path configFile = FabricLoader.getInstance().getConfigDir().resolve(CONFIG_NAME + ".toml"); - try { - if (Files.exists(configFile)) - return configFile.toRealPath().toString(); - } catch (IOException e) { - ReAuth.log.error("Could not resolve real path", e); - } - return configFile.toString(); - } - - private byte[] createSalt() { - byte[] salt = new byte[16]; - new SecureRandom().nextBytes(salt); - return salt; - } - - /** - * Updates the credentials in Config - * Credentials are encrypted; If encryption is unavailable they are not saved - * Empty String is not encrypted - */ - public void setCredentials(String profileName, String username, String password) { - if (crypto == null) - return; - Configuration.Credentials credentials = config.credentials; - credentials.profile = profileName; - if (!username.isEmpty()) - username = crypto.encryptString(username); - credentials.username = username; - if (!password.isEmpty()) - password = crypto.encryptString(password); - credentials.password = password; - - ConfigHolder cfg = AutoConfig.getConfigHolder(Configuration.class); - if (cfg instanceof ConfigManager) { - ((ConfigManager) cfg).save(); - } else { - ReAuth.log.warn("Unknown ConfigHolder: cannot save config"); - } - } - - /** - * Decrypt Username - * Empty String means offline Username -> return that - */ - public String getUsername() { - Configuration.Credentials credentials = config.credentials; - String username = credentials.username; - if (username.isEmpty()) - return credentials.profile; - if (crypto != null) - return crypto.decryptString(username); - return ""; - } - - /** - * Decrypt Password - * Empty String is not decrypted - */ - public String getPassword() { - String password = config.credentials.password; - if (!password.isEmpty() && crypto != null) - return crypto.decryptString(password); - return ""; - } - - public String getProfile() { - return config.credentials.profile; - } - - /** - * Is Cryptography available - */ - public boolean hasCrypto() { - return crypto != null; - } - - public boolean hasCredentials() { - return hasCrypto() && - !config.credentials.username.isEmpty() && - !config.credentials.password.isEmpty() && - !config.credentials.profile.isEmpty(); - } -} diff --git a/src/main/java/technicianlp/reauth/Configuration.java b/src/main/java/technicianlp/reauth/Configuration.java deleted file mode 100644 index 6c9c606..0000000 --- a/src/main/java/technicianlp/reauth/Configuration.java +++ /dev/null @@ -1,50 +0,0 @@ -package technicianlp.reauth; - -import me.sargunvohra.mcmods.autoconfig1u.ConfigData; -import me.sargunvohra.mcmods.autoconfig1u.annotation.Config; -import me.sargunvohra.mcmods.autoconfig1u.annotation.ConfigEntry; -import me.sargunvohra.mcmods.autoconfig1u.shadowed.blue.endless.jankson.Comment; - -@Config(name = ConfigWrapper.CONFIG_NAME) -public final class Configuration implements ConfigData { - @ConfigEntry.Gui.Excluded - @ConfigEntry.BoundedDiscrete(min = 1, max = 2) - @Comment("Version Number of the Configuration File") - public int version = 2; - -// @Comment("Credentials for login, encrypted with AES-CBC-PKCS5Padding and PBKDF2WithHmacSHA512 " + -// "based on the Path of this file and the contained Salt.\n" + -// "Manually editing one of the Encrypted Values or the Salt, is inadvisable - " + -// "Reset them to \"\" if required\n" + -// "Keep in mind that while the credentials are no longer humanly readable, " + -// "they could still be decrypted by malicious third parties") - @ConfigEntry.Gui.TransitiveObject - Credentials credentials = new Credentials(); - - static final class Credentials { - @Comment("One of the values required to decrypt the credentials\n" + - "See https://en.wikipedia.org/wiki/Salt_(cryptography) for details") - @ConfigEntry.Gui.Tooltip(count = 2) - public String salt = ""; - - @Comment("The Name of the last used Profile") - @ConfigEntry.Gui.Tooltip - public String profile = ""; - - @Comment("Your Username (encrypted)") - @ConfigEntry.Gui.Tooltip - public String username = ""; - - @Comment("Your Password (encrypted)") - @ConfigEntry.Gui.Tooltip - public String password = ""; - } - - @Override - public void validatePostLoad() throws ValidationException { - if (1 <= version && version <= 2) - ReAuth.config.onLoad(this); - else - throw new ValidationException("Unknown Version"); - } -} diff --git a/src/main/java/technicianlp/reauth/Crypto.java b/src/main/java/technicianlp/reauth/Crypto.java deleted file mode 100644 index 94b4290..0000000 --- a/src/main/java/technicianlp/reauth/Crypto.java +++ /dev/null @@ -1,100 +0,0 @@ -package technicianlp.reauth; - -import javax.crypto.Cipher; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.PBEKeySpec; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; -import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; -import java.util.Arrays; -import java.util.Base64; - -/** - * Basic encryption for credentials via PBKDF2 and AES-CBC
- * Key and IV for AES are derived from the PBKDF2-Hash
- */ -final class Crypto { - - private static final int iterations = 100_000; - - private final Cipher aes; - private final SecretKeyFactory pbkdf; - - private String key; - private byte[] salt; - private byte[] hash; - - public Crypto() throws GeneralSecurityException { - if (Cipher.getMaxAllowedKeyLength("AES") < 256) { - removeJceRestriction(); - } - aes = Cipher.getInstance("AES/CBC/PKCS5Padding"); - pbkdf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512"); - } - - private void removeJceRestriction() throws NoSuchAlgorithmException { - ReAuth.log.warn("Cryptography is restricted in this Java installation"); - ReAuth.log.warn("Please complain to Mojang for shipping a 5 year old Java version"); - new JceWorkaround().removeCryptographyRestrictions(); - if (Cipher.getMaxAllowedKeyLength("AES") < 256) { - ReAuth.log.error("Failed to remove cryptography restriction - saving credentials will not be available"); - throw new NoSuchAlgorithmException("AES 256 unsupported by JVM"); - } else { - ReAuth.log.info("Cryptography restriction removed successfully"); - } - } - - private byte[] crypt(int mode, byte[] secret) throws GeneralSecurityException { - SecretKeySpec secretKey = new SecretKeySpec(getHash(), 0, 32, "AES"); - IvParameterSpec ivParameterSpec = new IvParameterSpec(getHash(), 32, 16); - - aes.init(mode, secretKey, ivParameterSpec); - return aes.doFinal(secret); - } - - public String encryptString(String string) { - try { - byte[] raw = string.getBytes(StandardCharsets.UTF_8); - byte[] enc = crypt(Cipher.ENCRYPT_MODE, raw); - return Base64.getEncoder().encodeToString(enc); - } catch (GeneralSecurityException e) { - ReAuth.log.error("Unexpected Crypto Exception", e); - } - return ""; - } - - public String decryptString(String string) { - try { - byte[] raw = Base64.getDecoder().decode(string); - byte[] dec = crypt(Cipher.DECRYPT_MODE, raw); - return new String(dec, StandardCharsets.UTF_8); - } catch (GeneralSecurityException e) { - ReAuth.log.error("Unexpected Crypto Exception", e); - } - return ""; - } - - /** - * set Parameters, invalidate hash if parameters changed - */ - public void setup(String key, byte[] salt) { - if (key.equals(this.key) && Arrays.equals(salt, this.salt)) - return; - this.key = key; - this.salt = salt; - this.hash = null; - } - - /** - * Lazy generate hash - */ - private byte[] getHash() throws InvalidKeySpecException { - if (hash == null) { - hash = pbkdf.generateSecret(new PBEKeySpec(key.toCharArray(), salt, iterations, 512)).getEncoded(); - } - return hash; - } -} diff --git a/src/main/java/technicianlp/reauth/EventHandler.java b/src/main/java/technicianlp/reauth/EventHandler.java new file mode 100644 index 0000000..14d5ef0 --- /dev/null +++ b/src/main/java/technicianlp/reauth/EventHandler.java @@ -0,0 +1,118 @@ +package technicianlp.reauth; + +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.screen.ConnectScreen; +import net.minecraft.client.gui.screen.DisconnectedScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.screen.TitleScreen; +import net.minecraft.client.gui.screen.multiplayer.MultiplayerScreen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.client.util.Session; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; +import technicianlp.reauth.configuration.Profile; +import technicianlp.reauth.configuration.ProfileConstants; +import technicianlp.reauth.gui.MainScreen; +import technicianlp.reauth.mixin.DisconnectedScreenMixin; +import technicianlp.reauth.session.SessionChecker; +import technicianlp.reauth.session.SessionStatus; + +import java.util.List; + +public enum EventHandler { + ; + + public static final int STATE_X = 110; + public static final int STATE_Y = 10; + + public static void register() { + ScreenEvents.AFTER_INIT.register(EventHandler::afterInit); + ScreenEvents.BEFORE_INIT.register(EventHandler::beforeInit); + } + + public static void afterInit(MinecraftClient client, Screen screen, int scaledWidth, int scaledHeight) { + if (screen instanceof MultiplayerScreen) { + // Add Button to MultiplayerScreen + Screens.getButtons(screen).add(new ButtonWidget(5, 5, 100, 20, Text.translatable("reauth.gui.button"), + button -> openAuthenticationScreen(screen))); + ScreenEvents.afterRender(screen).register(EventHandler::afterRender); + ScreenMouseEvents.afterMouseClick(screen).register(EventHandler::afterMouseClick); + } else if (screen instanceof TitleScreen) { + // Support for Custom Main Menu (add button outside of viewport) + Screens.getButtons(screen).add(new ButtonWidget(-50, -50, 20, 20, Text.translatable("reauth.gui.button"), + button -> openAuthenticationScreen(screen))); + } else if (screen instanceof DisconnectedScreen) { + // Add Buttons to DisconnectedScreen if its reason is an invalid session + handleDisconnectScreen(screen); + } else if (screen instanceof ConnectScreen) { + // Save Screen to retrieve server later + ReconnectHelper.setConnectScreen((ConnectScreen) screen); + } + } + + private static void handleDisconnectScreen(Screen screen) { + if ("connect.failed".equals(ReconnectHelper.getTranslationKey(screen.getTitle(), false))) { + if (ReconnectHelper.getTranslationKey(((DisconnectedScreenMixin) screen).getReason(), true) + .startsWith("disconnect.loginFailed")) { + List buttons = Screens.getButtons(screen); + ClickableWidget menu = buttons.get(0); + + Profile profile = ReAuth.profiles.getProfile(); + Text retryText; + if (profile != null) { + retryText = Text.translatable("reauth.retry", profile.getValue(ProfileConstants.NAME, "Steve")); + } else { + retryText = Text.translatable("reauth.retry.disabled"); + } + ButtonWidget retryButton = new ButtonWidget(menu.x, menu.y + 25, 200, 20, retryText, + button -> ReconnectHelper.retryLogin(profile)); + if (profile == null || !ReconnectHelper.hasConnectionInfo()) { + retryButton.active = false; + } + buttons.add(retryButton); + } + } + } + + private static void openAuthenticationScreen(Screen screen) { + Screens.getClient(screen).setScreen(new MainScreen(screen)); + } + + public static void afterRender(Screen screen, MatrixStack matrices, int mouseX, int mouseY, float tickDelta) { + if (screen instanceof MultiplayerScreen) { + MinecraftClient client = Screens.getClient(screen); + Session session = client.getSession(); + SessionStatus state = SessionChecker.getSessionStatus(session.getAccessToken(), session.getUuid()); + String stateText = I18n.translate(state.getTranslationKey()); + + TextRenderer textRenderer = Screens.getTextRenderer(screen); + textRenderer.drawWithShadow(matrices, stateText, STATE_X, STATE_Y, 0xFFFFFFFF); + statusTextWidth = textRenderer.getWidth(stateText); + } + } + + private static int statusTextWidth; + + private static boolean withinTextBox(double x, double y) { + return x >= STATE_X && x < (STATE_X + statusTextWidth) && y >= STATE_Y && y < STATE_Y + 9; + } + + private static void afterMouseClick(Screen screen, double mouseX, double mouseY, int button) { + if (withinTextBox(mouseX, mouseY)) { + SessionChecker.invalidate(); + } + } + + public static void beforeInit(MinecraftClient client, Screen screen, int scaledWidth, int scaledHeight) { + if (screen instanceof MultiplayerScreen && + MinecraftClient.getInstance().currentScreen instanceof MultiplayerScreen && Screen.hasShiftDown()) { + SessionChecker.invalidate(); + } + } +} diff --git a/src/main/java/technicianlp/reauth/JceWorkaround.java b/src/main/java/technicianlp/reauth/JceWorkaround.java deleted file mode 100644 index a33bf8b..0000000 --- a/src/main/java/technicianlp/reauth/JceWorkaround.java +++ /dev/null @@ -1,59 +0,0 @@ -package technicianlp.reauth; - -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.security.PermissionCollection; -import java.util.Map; - -final class JceWorkaround { - - /** - * Java had for legal reasons limited the allowed strength of cryptographic algorithms. - * Historically to disable this restriction the so called "Java Cryptography Extension (JCE) Unlimited Strength - * Jurisdiction Policy Files" has to be installed within the JRE directory. - * Since update 151 (October 17, 2017) this restrictions can be disabled programmatically - * and has since been disabled by default in update 161 (January 16, 2018). - *

- * Since Mojang for some insane reason ships the 5 year old update 51 (July 14, 2015), installation of the policy files - * would be necessary. Since installation of those files cannot be required of the user, a workaround has been found in - * https://stackoverflow.com/questions/1179672 and is used to disable this restriction at runtime: - *

- * JceSecurity.isRestricted = false; - * JceSecurity.defaultPolicy.perms.clear(); - * JceSecurity.defaultPolicy.add(CryptoAllPermission.INSTANCE); - *

- * The alternative to this workaround would have been to drop the AES key-length from 256 bits to 128 bits. - */ - public void removeCryptographyRestrictions() { - try { - final Class jceSecurity = Class.forName("javax.crypto.JceSecurity"); - final Class cryptoPermissions = Class.forName("javax.crypto.CryptoPermissions"); - final Class cryptoAllPermission = Class.forName("javax.crypto.CryptoAllPermission"); - - setFinalField(jceSecurity, "isRestricted", null, true); - - final PermissionCollection defaultPolicy = getFieldValue(jceSecurity, "defaultPolicy", null); - ((Map) getFieldValue(cryptoPermissions, "perms", defaultPolicy)).clear(); - defaultPolicy.add(getFieldValue(cryptoAllPermission, "INSTANCE", null)); - } catch (final Exception e) { - ReAuth.log.error("Exception removing cryptography restrictions", e); - } - } - - @SuppressWarnings("unchecked") - private E getFieldValue(Class clz, String name, Object target) throws ReflectiveOperationException { - Field field = clz.getDeclaredField(name); - field.setAccessible(true); - return (E) field.get(target); - } - - @SuppressWarnings("SameParameterValue") - private void setFinalField(Class clz, String name, Object target, Object value) throws ReflectiveOperationException { - Field field = clz.getDeclaredField(name); - field.setAccessible(true); - final Field modifiersField = Field.class.getDeclaredField("modifiers"); - modifiersField.setAccessible(true); - modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); - field.set(target, value); - } -} diff --git a/src/main/java/technicianlp/reauth/ReAuth.java b/src/main/java/technicianlp/reauth/ReAuth.java index 82b46a3..1e1713e 100644 --- a/src/main/java/technicianlp/reauth/ReAuth.java +++ b/src/main/java/technicianlp/reauth/ReAuth.java @@ -1,23 +1,55 @@ package technicianlp.reauth; -import net.fabricmc.api.ModInitializer; +import net.fabricmc.api.ClientModInitializer; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; +import net.minecraft.client.resource.language.I18n; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import technicianlp.reauth.configuration.Config; +import technicianlp.reauth.configuration.ProfileList; +import technicianlp.reauth.mojangfix.MojangJavaFix; -public final class ReAuth implements ModInitializer { +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; + +public final class ReAuth implements ClientModInitializer { public static final Logger log = LogManager.getLogger("ReAuth"); - public static final AuthHelper auth = new AuthHelper(); + public static final ExecutorService executor = Executors.newCachedThreadPool(new ReAuthThreadFactory()); public static final VersionChecker versionCheck = new VersionChecker(); - public static ConfigWrapper config = new ConfigWrapper(); - public static ModContainer container; + public static final Config config = new Config(); + public static ProfileList profiles; + public static final BiFunction i18n = I18n::translate; + public static final ModContainer container = FabricLoader.getInstance().getModContainer("reauth").orElse(null); @Override - public void onInitialize() { - ConfigWrapper.registerConfig(); - container = FabricLoader.getInstance().getModContainer("reauth").orElse(null); + public void onInitializeClient() { + MojangJavaFix.fixMojangJava(); + profiles = config.getProfileList(); versionCheck.runVersionCheck(); + EventHandler.register(); + } + + private static final class ReAuthThreadFactory implements ThreadFactory { + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final ThreadGroup group = new ThreadGroup("ReAuth"); + + @Override + public Thread newThread( + @SuppressWarnings("ParameterNameDiffersFromOverriddenParameter") @NotNull Runnable runnable) { + Thread t = new Thread(this.group, runnable, "ReAuth-" + this.threadNumber.getAndIncrement()); + if (t.isDaemon()) { + t.setDaemon(false); + } + if (t.getPriority() != Thread.NORM_PRIORITY) { + t.setPriority(Thread.NORM_PRIORITY); + } + return t; + } } } diff --git a/src/main/java/technicianlp/reauth/ReconnectHelper.java b/src/main/java/technicianlp/reauth/ReconnectHelper.java new file mode 100644 index 0000000..3f61f3d --- /dev/null +++ b/src/main/java/technicianlp/reauth/ReconnectHelper.java @@ -0,0 +1,67 @@ +package technicianlp.reauth; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.ConnectScreen; +import net.minecraft.client.network.ServerAddress; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.text.TextContent; +import net.minecraft.text.TranslatableTextContent; +import technicianlp.reauth.authentication.flows.Flow; +import technicianlp.reauth.authentication.flows.Flows; +import technicianlp.reauth.configuration.Profile; +import technicianlp.reauth.gui.FlowScreen; +import technicianlp.reauth.mixin.ConnectScreenMixin; + +import java.net.InetSocketAddress; + +public enum ReconnectHelper { + ; + + private static ConnectScreen connectScreen; + + /** + * Extract the translationKey from the supplied {@link Text} + * + * @param nested whether to extract the key from the nested {@link Text} instead + */ + public static String getTranslationKey(Object component, boolean nested) { + if (component instanceof MutableText mutableText) { + TextContent contents = mutableText.getContent(); + if (contents instanceof TranslatableTextContent translatable) { + if (nested) { + Object[] args = translatable.getArgs(); + if (args.length >= 1) { + return getTranslationKey(args[0], false); + } + } else { + return translatable.getKey(); + } + } + } + return ""; + } + + public static void setConnectScreen(ConnectScreen connectScreen) { + ReconnectHelper.connectScreen = connectScreen; + } + + public static boolean hasConnectionInfo() { + return connectScreen != null; + } + + public static void retryLogin(Profile profile) { + Flow flow = FlowScreen.open(Flows::loginWithProfile, profile); + flow.thenRunAsync(ReconnectHelper::connect, MinecraftClient.getInstance()); + } + + private static void connect() { + if (connectScreen instanceof ConnectScreenMixin screenMixin) { + if (screenMixin.getConnection().getAddress() instanceof InetSocketAddress address) { + MinecraftClient minecraft = MinecraftClient.getInstance(); + ServerAddress server = new ServerAddress(address.getHostString(), address.getPort()); + ConnectScreen.connect(screenMixin.getParent(), minecraft, server, minecraft.getCurrentServerEntry()); + } + } + } +} diff --git a/src/main/java/technicianlp/reauth/ReflectionHelper.java b/src/main/java/technicianlp/reauth/ReflectionHelper.java deleted file mode 100644 index c20c42a..0000000 --- a/src/main/java/technicianlp/reauth/ReflectionHelper.java +++ /dev/null @@ -1,83 +0,0 @@ -package technicianlp.reauth; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; - -public final class ReflectionHelper { - - public static Method findMethod(Class clz, String name, Class... parameterTypes) { - try { - Method method = clz.getDeclaredMethod(name, parameterTypes); - method.setAccessible(true); - return method; - } catch (ReflectiveOperationException ignored) { - return null; - } - } - - public static T callMethod(Method method, Object target, Object... args) throws UncheckedInvocationTargetException { - try { - //noinspection unchecked - return (T) method.invoke(target, args); - } catch (InvocationTargetException e) { - throw new UncheckedInvocationTargetException(e); - } catch (ReflectiveOperationException e) { - throw new RuntimeException("Failed reflective Method call", e); - } - } - - public static Constructor findConstructor(Class clz, Class... parameterTypes) { - try { - Constructor constructor = clz.getDeclaredConstructor(parameterTypes); - constructor.setAccessible(true); - return constructor; - } catch (ReflectiveOperationException ignored) { - return null; - } - } - - public static T callConstructor(Constructor constructor, Object... args) throws UncheckedInvocationTargetException { - try { - return constructor.newInstance(args); - } catch (InvocationTargetException e) { - throw new UncheckedInvocationTargetException(e); - } catch (ReflectiveOperationException e) { - throw new RuntimeException("Failed reflective Constructor call", e); - } - } - - public static Field findField(Class clz, String name) { - try { - Field field = clz.getDeclaredField(name); - field.setAccessible(true); - return field; - } catch (ReflectiveOperationException ignored) { - return null; - } - } - - public static void setField(Field field, Object target, Object value){ - try { - field.set(target, value); - } catch (ReflectiveOperationException throwable) { - throw new RuntimeException("Failed Reflective set", throwable); - } - } - - public static T getField(Field field, Object target){ - try { - //noinspection unchecked - return (T) field.get(target); - } catch (ReflectiveOperationException throwable) { - throw new RuntimeException("Failed Reflective get", throwable); - } - } - - public static class UncheckedInvocationTargetException extends RuntimeException { - public UncheckedInvocationTargetException(InvocationTargetException e) { - super(e.getCause()); - } - } -} diff --git a/src/main/java/technicianlp/reauth/VersionChecker.java b/src/main/java/technicianlp/reauth/VersionChecker.java index 7243d60..56d08ab 100644 --- a/src/main/java/technicianlp/reauth/VersionChecker.java +++ b/src/main/java/technicianlp/reauth/VersionChecker.java @@ -1,12 +1,6 @@ package technicianlp.reauth; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonDeserializationContext; -import com.google.gson.JsonDeserializer; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; +import com.google.gson.*; import com.google.gson.reflect.TypeToken; import net.minecraft.SharedConstants; import org.apache.commons.io.IOUtils; @@ -15,29 +9,33 @@ import java.lang.reflect.Type; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; +import java.util.regex.Pattern; public final class VersionChecker implements Runnable { private static final String MC_VERSION = SharedConstants.getGameVersion().getReleaseTarget(); - private static final String JSON_URL = "https://github.com/TechnicianLP/ReAuth/raw/master/update.json"; - private static final Type mapType = new TypeToken>() {}.getType(); + private static final String JSON_URL = "https://github.com/NgoKimPhu/ReAuth/raw/master/update.json"; + private static final Type mapType = new TypeToken>() { + }.getType(); + private static final Pattern PATTERN_VERSION_DELIMS = Pattern.compile("[.-]"); private Status status = Status.UNKNOWN; - private String changes = null; + private String changes; private final Gson gson; public VersionChecker() { - gson = new GsonBuilder() - .registerTypeAdapter(VersionJson.class, new VersionJsonDeserializer()) - .create(); + this.gson = new GsonBuilder() + .registerTypeAdapter(VersionJson.class, new VersionJsonDeserializer()) + .create(); } public void runVersionCheck() { - status = Status.UNKNOWN; - changes = null; + this.status = Status.UNKNOWN; + this.changes = null; new Thread(this, "ReAuth Version Check").start(); } @@ -50,7 +48,7 @@ public void run() { String data = IOUtils.toString(inputstream, StandardCharsets.UTF_8); inputstream.close(); - VersionJson json = gson.fromJson(data, VersionJson.class); + VersionJson json = this.gson.fromJson(data, VersionJson.class); String latest = json.versions.get(MC_VERSION + "-recommended"); String current = ReAuth.container.getMetadata().getVersion().getFriendlyString(); @@ -59,9 +57,9 @@ public void run() { if (latest != null) { int latestVer = versionToInt(latest); if (currentId < latestVer) { - status = Status.OUTDATED; + this.status = Status.OUTDATED; } else { - status = Status.OK; + this.status = Status.OK; } } @@ -70,52 +68,55 @@ public void run() { int versionId = versionToInt(entry.getKey()); if (versionId > currentLatest) { currentLatest = versionId; - changes = entry.getValue(); + this.changes = entry.getValue(); } } ReAuth.log.info("Version check complete"); } catch (Exception e) { ReAuth.log.warn("Failed to process update information", e); - status = Status.FAILED; + this.status = Status.FAILED; } } public Status getStatus() { - return status; + return this.status; } public String getChanges() { - return changes; + return this.changes; } private static int versionToInt(String version) { - String[] split = version.split("\\.", 3); - int ver = 0; - for (String s : split) { - ver = (ver << 8) | Integer.parseInt(s); - } - return ver; + return Arrays.stream(PATTERN_VERSION_DELIMS.split(version)).limit(3) + .mapToInt(Integer::parseInt).reduce(0, (ver, i) -> (ver << 8) | i); } - private static class VersionJson { - Map versions = new HashMap<>(); - Map changelog = new HashMap<>(); + private static final class VersionJson { + final Map versions = new HashMap<>(); + final Map changelog = new HashMap<>(); - public VersionJson(Map versions, Map changelog) { - if (versions != null) + private VersionJson(Map versions, Map changelog) { + if (versions != null) { this.versions.putAll(versions); - if (changelog != null) + } + if (changelog != null) { this.changelog.putAll(changelog); + } } } private static class VersionJsonDeserializer implements JsonDeserializer { @Override - public VersionJson deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - if (json.isJsonObject()) { - JsonObject object = (JsonObject) json; - Map versions = context.deserialize(object.get("promos-fabric"), mapType); - Map changelog = context.deserialize(object.get(MC_VERSION + "-fabric"), mapType); + public VersionJson deserialize(JsonElement jsonElement, Type type, + @SuppressWarnings("ParameterNameDiffersFromOverriddenParameter") + JsonDeserializationContext context) throws + JsonParseException { + if (jsonElement.isJsonObject()) { + JsonObject object = (JsonObject) jsonElement; + Map versions = + context.deserialize(object.get("promos-fabric"), mapType); + Map changelog = + context.deserialize(object.get(MC_VERSION + "-fabric"), mapType); return new VersionJson(versions, changelog); } return null; diff --git a/src/main/java/technicianlp/reauth/configuration/Config.java b/src/main/java/technicianlp/reauth/configuration/Config.java new file mode 100644 index 0000000..b4cfde5 --- /dev/null +++ b/src/main/java/technicianlp/reauth/configuration/Config.java @@ -0,0 +1,50 @@ +package technicianlp.reauth.configuration; + +import com.electronwill.nightconfig.core.file.CommentedFileConfig; +import com.electronwill.nightconfig.core.file.FileConfig; +import com.electronwill.nightconfig.toml.TomlFormat; +import net.fabricmc.loader.api.FabricLoader; +import technicianlp.reauth.ReAuth; +import technicianlp.reauth.crypto.Crypto; + +import java.io.IOException; + +public final class Config { + public static final String CONFIG_NAME = "reauth"; + public static final String VERSION_PATH = "version"; + + private final CommentedFileConfig config; + private final ProfileList profileList; + + public Config() { + this.config = CommentedFileConfig.builder( + FabricLoader.getInstance().getConfigDir().resolve(CONFIG_NAME + ".toml"), + TomlFormat.instance()).autosave().build(); + this.config.load(); + Crypto.updateConfigPath(getPath(this.config)); + this.config.set(VERSION_PATH, 3); + this.config.setComment(VERSION_PATH, "Version Number of the Configuration File"); + this.profileList = new ProfileList(this, this.config); + } + + public void save() { + this.config.save(); + } + + public ProfileList getProfileList() { + return this.profileList; + } + + /** + * Get the Absolute path of the Config with symlinks resolved. Fall back to "local" path if that lookup fails + * (somehow) + */ + private static String getPath(FileConfig config) { + try { + return config.getNioPath().toRealPath().toString(); + } catch (IOException e) { + ReAuth.log.error("Could not resolve real path", e); + return config.getNioPath().toString(); + } + } +} diff --git a/src/main/java/technicianlp/reauth/configuration/Profile.java b/src/main/java/technicianlp/reauth/configuration/Profile.java new file mode 100644 index 0000000..a237047 --- /dev/null +++ b/src/main/java/technicianlp/reauth/configuration/Profile.java @@ -0,0 +1,40 @@ +package technicianlp.reauth.configuration; + +import com.electronwill.nightconfig.core.CommentedConfig; + +import java.util.concurrent.CompletableFuture; + +public final class Profile { + + private final CommentedConfig config; + + Profile(CommentedConfig config) { + this.config = config; + } + + public String getValue(String key) { + return this.config.get(key); + } + + public String getValue(String key, String defaultValue) { + return this.config.getOrElse(key, defaultValue); + } + + public CompletableFuture get(String key) { + return CompletableFuture.completedFuture(this.getValue(key)); + } + + CommentedConfig getConfig() { + return this.config; + } + + @Override + public boolean equals(Object obj) { + return this == obj || obj instanceof Profile && this.config.equals(((Profile) obj).config); + } + + @Override + public int hashCode() { + return this.config == null ? 0 : this.config.hashCode(); + } +} diff --git a/src/main/java/technicianlp/reauth/configuration/ProfileList.java b/src/main/java/technicianlp/reauth/configuration/ProfileList.java new file mode 100644 index 0000000..aba7981 --- /dev/null +++ b/src/main/java/technicianlp/reauth/configuration/ProfileList.java @@ -0,0 +1,98 @@ +package technicianlp.reauth.configuration; + +import com.electronwill.nightconfig.core.CommentedConfig; + +import java.util.*; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public final class ProfileList { + public static final String PROFILES_PATH = "profiles"; + + private final Config configuration; + private final List profilesProperty; + + private final Supplier configSupplier; + + ProfileList(Config configuration, CommentedConfig config) { + this.configuration = configuration; + this.configSupplier = config::createSubConfig; + this.profilesProperty = config.getOrElse(PROFILES_PATH, + () -> { + List newList = new ArrayList<>(); + config.set(PROFILES_PATH, newList); + return newList; + }); + config.setComment(PROFILES_PATH, "Saved Profiles. Check Documentation for Info & Syntax"); + correctProfiles(this.profilesProperty); + this.saveProfiles(); + } + + public void storeProfile(Profile profile) { + List list = this.profilesProperty; + CommentedConfig profileConfig = profile.getConfig(); + list.removeIf(profileConfig::equals); + list.add(0, profileConfig); + this.saveProfiles(); + } + + public Profile getProfile() { + return this.getProfileStream().findFirst().orElse(null); + } + + public List getProfiles() { + return this.getProfileStream().collect(Collectors.toList()); + } + + private Stream getProfileStream() { + return this.profilesProperty.stream() + .filter(profile -> !ProfileConstants.PROFILE_TYPE_NONE.equals( + profile.getOrElse(ProfileConstants.PROFILE_TYPE, ProfileConstants.PROFILE_TYPE_NONE))) + .map(Profile::new); + } + + Profile createProfile(Map data) { + Map orderedData = new TreeMap<>(ProfileConstants::compareProfileKeys); + orderedData.putAll(data); + + CommentedConfig config = this.configSupplier.get(); + orderedData.forEach(config::set); + + return new Profile(config); + } + + private static void correctProfiles(Collection profileList) { + for (CommentedConfig profile : profileList) { + Iterator iterator; + for (iterator = profile.entrySet().iterator(); iterator.hasNext(); ) { + CommentedConfig.Entry entry = iterator.next(); + Object value = entry.getValue(); + if (value == null) { + iterator.remove(); + } else if (!(value instanceof String)) { + entry.setValue(value.toString()); + } + } + } + profileList.removeIf(CommentedConfig::isEmpty); + } + + /** + * Save the list of Profiles to config Add a dummy profile if the list is empty + */ + private void saveProfiles() { + List list = this.profilesProperty; + if (list.isEmpty()) { + list.add(this.createPlaceholderConfig()); + } + this.configuration.save(); + } + + private CommentedConfig createPlaceholderConfig() { + CommentedConfig config = this.configSupplier.get(); + config.set(ProfileConstants.PROFILE_TYPE, ProfileConstants.PROFILE_TYPE_NONE); + return config; + } + +} diff --git a/src/main/java/technicianlp/reauth/gui/AbstractScreen.java b/src/main/java/technicianlp/reauth/gui/AbstractScreen.java new file mode 100644 index 0000000..df6adb1 --- /dev/null +++ b/src/main/java/technicianlp/reauth/gui/AbstractScreen.java @@ -0,0 +1,104 @@ +package technicianlp.reauth.gui; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +abstract class AbstractScreen extends Screen { + + static final int BUTTON_WIDTH = 196; + + private final Screen parent; + + private final CompletableFuture closed = new CompletableFuture<>(); + + protected int baseX; + protected int centerX; + protected int baseY; + protected int centerY; + protected static final int screenWidth = 300; + protected static final int screenHeight = 175; + + AbstractScreen(String title) { + this(title, MinecraftClient.getInstance().currentScreen); + } + + AbstractScreen(String title, Screen parent) { + super(Text.translatable(title)); + this.parent = parent; + } + + @Override + public void init() { + super.init(); + Objects.requireNonNull(this.client).keyboard.setRepeatEvents(true); + + this.centerX = this.width / 2; + this.baseX = this.centerX - screenWidth / 2; + this.centerY = this.height / 2; + this.baseY = this.centerY - screenHeight / 2; + + ButtonWidget cancel = new ButtonWidget(this.centerX + screenWidth / 2 - 22, this.baseY + 2, 20, 20, + Text.translatable("reauth.gui.close"), button -> this.close()); + this.addDrawableChild(cancel); + } + + @Override + public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) { + if (this.closed.isDone()) { + try { + this.requestClose(this.closed.get()); + } catch (InterruptedException | ExecutionException e) { + this.requestClose(true); + } + return; + } + + this.renderBackground(matrices); + super.render(matrices, mouseX, mouseY, delta); + + drawCenteredText(matrices, this.textRenderer, this.title, this.centerX, this.baseY + 8, 0xFFFFFF); + } + + protected final void transitionScreen(Screen newScreen) { + Objects.requireNonNull(this.client).setScreen(newScreen); + } + + protected void requestClose(boolean completely) { + if (!MinecraftClient.getInstance().isOnThread()) { + this.closed.complete(completely); + return; + } + + Screen parent = this.parent; + if (completely) { + while (parent instanceof AbstractScreen abstractScreen) { + parent = abstractScreen.parent; + } + } + this.transitionScreen(parent); + } + + /** + * Method called to request this Screen to close itself + */ + @Override + public final void close() { + this.requestClose(false); + } + + /** + * Called once this Screen is closed + */ + @Override + public void removed() { + super.removed(); + Objects.requireNonNull(this.client).keyboard.setRepeatEvents(false); + } +} diff --git a/src/main/java/technicianlp/reauth/gui/AuthScreen.java b/src/main/java/technicianlp/reauth/gui/AuthScreen.java deleted file mode 100644 index 6b004bc..0000000 --- a/src/main/java/technicianlp/reauth/gui/AuthScreen.java +++ /dev/null @@ -1,237 +0,0 @@ -package technicianlp.reauth.gui; - -import com.mojang.authlib.exceptions.AuthenticationException; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.Element; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.gui.widget.ButtonWidget; -import net.minecraft.client.gui.widget.CheckboxWidget; -import net.minecraft.client.gui.widget.TextFieldWidget; -import net.minecraft.client.resource.language.I18n; -import net.minecraft.client.util.math.MatrixStack; -import net.minecraft.text.LiteralText; -import net.minecraft.text.TranslatableText; -import org.lwjgl.glfw.GLFW; -import technicianlp.reauth.AuthHelper; -import technicianlp.reauth.ReAuth; -import technicianlp.reauth.VersionChecker; -import technicianlp.reauth.integration.ClothConfigIntegration; - -import java.awt.Color; - -public final class AuthScreen extends Screen { - - private TextFieldWidget username; - private PasswordFieldWidget pw; - private ButtonWidget confirm; - private ButtonWidget cancel; - private CheckboxWidget save; - private ButtonWidget config; - - private Screen prev; - - private int baseY; - - private String message = ""; - - public AuthScreen(Screen prev) { - super(new TranslatableText("reauth.gui.auth.title")); - this.prev = prev; - } - - public AuthScreen(Screen prev, String message) { - this(prev); - this.message = message; - } - - @Override - public void init() { - super.init(); - this.client.keyboard.setRepeatEvents(true); - - this.baseY = this.height / 2 - 110 / 2; - - this.username = new TextFieldWidget(this.textRenderer, this.width / 2 - 155, this.baseY + 15, 2 * 155, 20, new TranslatableText("reauth.gui.auth.username")); - this.username.setMaxLength(512); - this.username.setText(ReAuth.config.getUsername()); - addButton(username); - - this.pw = new PasswordFieldWidget(this.textRenderer, this.width / 2 - 155, this.baseY + 60, 2 * 155, 20, new TranslatableText("reauth.gui.auth.password")); - this.pw.setMaxLength(Short.MAX_VALUE); - this.pw.setText(ReAuth.config.getPassword()); - addButton(this.pw); - - setInitialFocus(username.getText().isEmpty() ? username : pw); - - this.save = new CheckboxWidget(this.width / 2 - 155, this.baseY + 85, 2 * 155, 20, new TranslatableText("reauth.gui.auth.checkbox"), !pw.getText().isEmpty()); - if (ReAuth.config.hasCrypto()) { - addButton(this.save); - } - - this.confirm = new ButtonWidget(this.width / 2 - 155, this.baseY + 110, 153, 20, LiteralText.EMPTY, b -> doLogin()); - addButton(confirm); - this.cancel = new ButtonWidget(this.width / 2 + 2, this.baseY + 110, 155, 20, new TranslatableText("gui.cancel"), b -> this.client.openScreen(prev)); - addButton(cancel); - - this.config = new ButtonWidget(this.width - 80, this.height - 25, 75, 20, new TranslatableText("reauth.gui.auth.config"), b -> { - this.client.openScreen(ClothConfigIntegration.getConfigScreen(this)); - }); - if (ClothConfigIntegration.isAvailable()) { - addButton(config); - } - - if (message.isEmpty() && ReAuth.versionCheck.getStatus() == VersionChecker.Status.OUTDATED) { - String msg = ReAuth.versionCheck.getChanges(); - if (msg != null) { - message = I18n.translate("reauth.gui.auth.update", msg); - } - } - } - - @Override - public void render(MatrixStack matrices, int mouseX, int mouseY, float partialTicks) { - this.renderBackground(matrices); - - drawCenteredString2(matrices, this.textRenderer, I18n.translate("reauth.gui.auth.text1"), this.width / 2, this.baseY, Color.WHITE.getRGB()); - drawCenteredString2(matrices, this.textRenderer, I18n.translate("reauth.gui.auth.text2"), this.width / 2, this.baseY + 45, Color.WHITE.getRGB()); - if (!this.message.isEmpty()) { - drawCenteredString2(matrices, this.textRenderer, this.message, this.width / 2, this.baseY - 15, 0xFFFFFF); - } - - if (!ReAuth.config.hasCrypto()) { - this.textRenderer.draw(matrices, I18n.translate("reauth.gui.auth.noCrypto"), this.width / 2 - 155, this.baseY + 90, Color.WHITE.getRGB()); - } - - LoginType status = getLoginType(); - this.confirm.setMessage(new TranslatableText(status.getTranslation())); - this.confirm.active = status.isActive(); - - super.render(matrices, mouseX, mouseY, partialTicks); - } - - /** - * Sets the focus to the given TextFieldWidget - */ - private void focus(TextFieldWidget widget) { - Element old = getFocused(); - if (old instanceof TextFieldWidget) - ((TextFieldWidget) old).setSelected(false); - if (widget != null) - widget.setSelected(true); - focusOn(widget); - } - - @Override - public boolean keyPressed(int keyCode, int scanCode, int modifiers) { - if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { - Element focus = getFocused(); - if (focus == username) { - focus(pw); - return true; - } else if (focus == pw) { - doLogin(); - return true; - } - } - return super.keyPressed(keyCode, scanCode, modifiers); - } - - /** - * Determines which {@link LoginType} is applicable
- * - {@link LoginType#ONLINE} requires a Password
- * - {@link LoginType#OFFLINE} needs the name to match the Regex for valid Minecraft Names - */ - private LoginType getLoginType() { - String user = this.username.getText(); - if (user.isEmpty()) { - return LoginType.NONE; - } else if (this.pw.getText().isEmpty()) { - if (ReAuth.auth.isValidName(user)) { - return LoginType.OFFLINE; - } else { - return LoginType.NONE; - } - } else { - return LoginType.ONLINE; - } - } - - /** - * Calls the {@link AuthHelper} to do the Login and handles Errors - * Closes the Screen if successful - */ - private void doLogin() { - boolean success = false; - try { - LoginType type = getLoginType(); - switch (type) { - case ONLINE: - ReAuth.auth.login(this.username.getText(), this.pw.getPassword(), this.save.isChecked()); - break; - case OFFLINE: - ReAuth.auth.offline(this.username.getText()); - break; - default: - return; - } - this.message = I18n.translate("reauth.login.success"); - success = true; - } catch (AuthenticationException e) { - this.message = I18n.translate("reauth.login.fail", e.getMessage()); - ReAuth.log.error("Login failed:", e); - } catch (Exception e) { - this.message = I18n.translate("reauth.login.error", e.getMessage()); - ReAuth.log.error("Error:", e); - } - if (success) - onClose(); - } - - /** - * Method called to request this Screen to close itself (unfortunate MCP name) - */ - @Override - public void onClose() { - this.client.openScreen(prev); - } - - /** - * Called once this Screen is closed (unfortunate MCP name) - */ - @Override - public void removed() { - super.removed(); - this.pw.setPassword(new char[0]); - this.client.keyboard.setRepeatEvents(false); - } - - private enum LoginType { - NONE(false, "none"), - ONLINE(true, "online"), - OFFLINE(true, "offline"); - - private final boolean active; - private final String translation; - - LoginType(boolean active, String translation) { - this.active = active; - this.translation = translation; - } - - public boolean isActive() { - return active; - } - - public String getTranslation() { - return "reauth.gui.auth.confirm." + translation; - } - } - - /** - * for 1.16.x compat this needs to be copied - * the superclass method changes from instance to static between 1.16.1 and 1.16.2 - */ - private void drawCenteredString2(MatrixStack matrices, TextRenderer textRenderer, String text, int centerX, int y, int color) { - textRenderer.drawWithShadow(matrices, text, (float)(centerX - textRenderer.getWidth(text) / 2), (float)y, color); - } -} diff --git a/src/main/java/technicianlp/reauth/gui/FlowScreen.java b/src/main/java/technicianlp/reauth/gui/FlowScreen.java new file mode 100644 index 0000000..fa95b1f --- /dev/null +++ b/src/main/java/technicianlp/reauth/gui/FlowScreen.java @@ -0,0 +1,166 @@ +package technicianlp.reauth.gui; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; +import net.minecraft.util.Util; +import technicianlp.reauth.ReAuth; +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.authentication.flows.*; +import technicianlp.reauth.configuration.Profile; +import technicianlp.reauth.session.SessionHelper; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.BiFunction; +import java.util.regex.Pattern; + +public final class FlowScreen extends AbstractScreen implements FlowCallback { + + private static final String[] EMPTY_STRINGS_ARRAY = new String[0]; + private static final Pattern PATTERN_LINE_BREAK = Pattern.compile("\\R"); + + public static F open(BiFunction flowConstructor, P param) { + FlowScreen screen = new FlowScreen(); + F flow = flowConstructor.apply(param, screen); + screen.flow = flow; + MinecraftClient.getInstance().setScreen(screen); + return flow; + } + + private Flow flow; + private FlowStage stage = FlowStage.INITIAL; + private String[] formatArgs = EMPTY_STRINGS_ARRAY; + + public FlowScreen() { + super("reauth.gui.title.flow"); + } + + @Override + public void init() { + super.init(); + + int buttonWidth = 196; + int buttonWidthH = buttonWidth / 2; + + this.formatArgs = EMPTY_STRINGS_ARRAY; + if (this.stage == FlowStage.MS_AWAIT_AUTH_CODE && this.flow instanceof AuthorizationCodeFlow) { + try { + URL url = new URL(((AuthorizationCodeFlow) this.flow).getLoginUrl()); + this.addDrawableChild(new ButtonWidget(this.centerX - buttonWidthH, + this.baseY + screenHeight - 42, buttonWidth, 20, Text.translatable("reauth.msauth.button" + + ".browser"), button -> Util.getOperatingSystem().open(url))); + } catch (MalformedURLException e) { + ReAuth.log.error("Browser button failed", e); + } + } else if (this.stage == FlowStage.MS_POLL_DEVICE_CODE && this.flow instanceof DeviceCodeFlow flow) { + try { + if (CompletableFuture.allOf(flow.getLoginUrl(), flow.getCode()).isDone()) { + String urlString = flow.getLoginUrl().join(); + String code = flow.getCode().join(); + URL url = new URL(urlString); + this.addDrawableChild(new ButtonWidget(this.centerX - buttonWidthH, + this.baseY + screenHeight - 42, buttonWidth, 20, + Text.translatable("reauth.msauth.button.browser"), + button -> Util.getOperatingSystem().open(url))); + this.formatArgs = new String[]{urlString, code}; + } + } catch (MalformedURLException e) { + ReAuth.log.error("Browser button failed", e); + } + } + } + + @Override + public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) { + super.render(matrices, mouseX, mouseY, delta); + + String text = I18n.translate(this.stage.getRawName(), (Object[]) this.formatArgs); + String[] lines = PATTERN_LINE_BREAK.split(text); + int height = lines.length * 9; + for (String s : lines) { + if (s.startsWith("$")) { + height += 9; + } + } + + int y = this.centerY - height / 2; + for (String line : lines) { + if (line.startsWith("$")) { + line = line.substring(1); + matrices.push(); + matrices.scale(2, 2, 1); + this.textRenderer.drawWithShadow(matrices, line, + (this.centerX - this.textRenderer.getWidth(line)) / 2f, y / 2f, 0xFFFFFFFF); + y += 18; + matrices.pop(); + } else { + drawCenteredText(matrices, this.textRenderer, line, this.centerX, y, 0xFFFFFFFF); + y += 9; + } + } + } + + @Override + public void removed() { + super.removed(); + if (this.stage != FlowStage.FINISHED) { + this.flow.cancel(); + } + } + + @Override + public void onSessionComplete(SessionData session, Throwable throwable) { + if (throwable == null) { + SessionHelper.setSession(session); + ReAuth.log.info("Login complete"); + } else { + if (throwable instanceof CancellationException || throwable.getCause() instanceof CancellationException) { + ReAuth.log.info("Login cancelled"); + } else { + ReAuth.log.error("Login failed", throwable); + } + } + } + + @Override + public void onProfileComplete(Profile profile, Throwable throwable) { + if (throwable == null) { + ReAuth.profiles.storeProfile(profile); + ReAuth.log.info("Profile saved successfully"); + } else { + if (throwable instanceof CancellationException || throwable.getCause() instanceof CancellationException) { + ReAuth.log.info("Profile saving cancelled"); + } else { + ReAuth.log.error("Profile failed to save", throwable); + } + } + } + + @Override + public void transitionStage(FlowStage newStage) { + this.stage = newStage; + ReAuth.log.info(this.stage.getLogLine()); + this.init(MinecraftClient.getInstance(), this.width, this.height); + + if (newStage == FlowStage.MS_AWAIT_AUTH_CODE && this.flow instanceof AuthorizationCodeFlow) { + try { + Util.getOperatingSystem().open(new URL(((AuthorizationCodeFlow) this.flow).getLoginUrl())); + } catch (MalformedURLException e) { + ReAuth.log.error("Failed to open page", e); + } + } else if (newStage == FlowStage.FINISHED) { + this.requestClose(true); + } + } + + @Override + public Executor getExecutor() { + return ReAuth.executor; + } +} diff --git a/src/main/java/technicianlp/reauth/gui/MainScreen.java b/src/main/java/technicianlp/reauth/gui/MainScreen.java new file mode 100644 index 0000000..8ba4a13 --- /dev/null +++ b/src/main/java/technicianlp/reauth/gui/MainScreen.java @@ -0,0 +1,95 @@ +package technicianlp.reauth.gui; + +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; +import technicianlp.reauth.ReAuth; +import technicianlp.reauth.VersionChecker; +import technicianlp.reauth.authentication.flows.Flows; +import technicianlp.reauth.configuration.Profile; +import technicianlp.reauth.configuration.ProfileConstants; + +import java.util.List; +import java.util.stream.Collectors; + +public final class MainScreen extends AbstractScreen { + private String message; + + public MainScreen(Screen parent) { + super("reauth.gui.title.main", parent); + } + + @Override + public void init() { + super.init(); + + int buttonWidthH = BUTTON_WIDTH / 2; + int y = this.centerY - 55; + + SaveButton.ITooltip saveButtonTooltip = + (button, matrixStack, mouseX, mouseY) -> this.renderOrderedTooltip(matrixStack, + this.textRenderer.wrapLines(Text.translatable("reauth.gui.button.save.tooltip"), 250), + mouseX, mouseY); + SaveButton saveButton = new SaveButton(this.centerX - buttonWidthH, y + 70, + Text.translatable("reauth.gui.button.save"), saveButtonTooltip); + + List profiles = ReAuth.profiles.getProfiles(); + if (profiles.isEmpty()) { + ButtonWidget profileButton = new ButtonWidget(this.centerX - buttonWidthH, y + 10, BUTTON_WIDTH, 20, + Text.translatable("reauth.gui.noProfile"), button -> { + }); + profileButton.active = false; + this.addDrawableChild(profileButton); + } else { + List texts = profiles.stream().map(profile -> Text.translatable("reauth.gui.profile", + profile.getValue(ProfileConstants.NAME, "Steve"))).collect(Collectors.toList()); + this.addDrawableChild( + new MultiOptionButton(this, this.centerX - buttonWidthH, y + 10, BUTTON_WIDTH, 20, + texts, idx -> FlowScreen.open(Flows::loginWithProfile, profiles.get(idx)))); + } + + this.addDrawableChild(new ButtonWidget(this.centerX - buttonWidthH, y + 45, buttonWidthH - 1, 20, + Text.translatable("reauth.gui.button.authcode"), button -> FlowScreen.open(Flows::loginWithAuthCode, + saveButton.isChecked()))); + this.addDrawableChild(new ButtonWidget(this.centerX + 1, y + 45, buttonWidthH - 1, 20, Text.translatable( + "reauth.gui.button.devicecode"), button -> FlowScreen.open(Flows::loginWithDeviceCode, + saveButton.isChecked()))); + this.addDrawableChild(saveButton); + this.addDrawableChild(new ButtonWidget(this.centerX - buttonWidthH, y + 105, BUTTON_WIDTH, 20, + Text.translatable("reauth.gui.button.offline"), + button -> this.transitionScreen(new OfflineLoginScreen()))); + + VersionChecker.Status result = ReAuth.versionCheck.getStatus(); + if (result == VersionChecker.Status.OUTDATED) { + // Cannot be null but is marked as such :( + String changes = ReAuth.versionCheck.getChanges(); + if (changes != null) { + this.message = I18n.translate("reauth.gui.auth.update", changes); + } + } + } + + @Override + public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) { + super.render(matrices, mouseX, mouseY, delta); + + int x = this.centerX - BUTTON_WIDTH / 2; + this.textRenderer.drawWithShadow(matrices, I18n.translate("reauth.gui.text.profile"), + x, this.centerY - 55, 0xA0A0A0); + this.textRenderer.drawWithShadow(matrices, I18n.translate("reauth.gui.text.microsoft"), + x, this.centerY - 20, 0xA0A0A0); + this.textRenderer.drawWithShadow(matrices, I18n.translate("reauth.gui.text.offline"), + x, this.centerY + 40, 0xA0A0A0); + + if (this.message != null) { + this.textRenderer.drawWithShadow(matrices, this.message, x, this.baseY + 20, 0xFFFFFFFF); + } + } + + @Override + protected void requestClose(boolean completely) { + super.requestClose(true); + } +} diff --git a/src/main/java/technicianlp/reauth/gui/MultiOptionButton.java b/src/main/java/technicianlp/reauth/gui/MultiOptionButton.java new file mode 100644 index 0000000..17c8647 --- /dev/null +++ b/src/main/java/technicianlp/reauth/gui/MultiOptionButton.java @@ -0,0 +1,61 @@ +package technicianlp.reauth.gui; + +import com.mojang.blaze3d.systems.RenderSystem; +import net.minecraft.client.gui.DrawableHelper; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.render.GameRenderer; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.screen.ScreenTexts; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.IntConsumer; + +public class MultiOptionButton extends ButtonWidget { + static final Identifier SERVER_SELECTION_TEXTURE = new Identifier("textures/gui/server_selection.png"); + + private final Screen screen; + + private final IntConsumer onSelect; + private final List options; + private int currentOptionIndex; + + public MultiOptionButton(Screen screen, int x, int y, int width, int height, List options, + IntConsumer onSelect) { + super(x, y, width, height, options.stream().findFirst().orElse(ScreenTexts.EMPTY), button -> {}); + this.screen = screen; + this.onSelect = onSelect; + this.options = new ArrayList<>(options); + } + + @Override + public void renderButton(MatrixStack matrices, int mouseX, int mouseY, float delta) { + super.renderButton(matrices, mouseX, mouseY, delta); + if (this.isHovered()) { + RenderSystem.setShaderTexture(0, SERVER_SELECTION_TEXTURE); + RenderSystem.setShader(GameRenderer::getPositionTexShader); + RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f); + boolean isDropdownHovered = this.hovered && mouseX > this.x + this.width - 16; + DrawableHelper.drawTexture(matrices, this.x + this.width - 17, this.y + 6, 64.0f, + isDropdownHovered ? 52f : 20f, 16, 12, 256, 256); + if (isDropdownHovered) { + this.screen.renderTooltip(matrices, + Text.of((this.currentOptionIndex + 1) + "/" + this.options.size()), + mouseX, mouseY); + } + } + } + + @Override + public void onClick(double mouseX, double mouseY) { + if (mouseX > this.x + this.width - 16) { + this.currentOptionIndex = (this.currentOptionIndex + 1) % this.options.size(); + this.setMessage(this.options.get(this.currentOptionIndex)); + } else { + this.onSelect.accept(this.currentOptionIndex); + } + } +} diff --git a/src/main/java/technicianlp/reauth/gui/OfflineLoginScreen.java b/src/main/java/technicianlp/reauth/gui/OfflineLoginScreen.java new file mode 100644 index 0000000..87b5f67 --- /dev/null +++ b/src/main/java/technicianlp/reauth/gui/OfflineLoginScreen.java @@ -0,0 +1,75 @@ +package technicianlp.reauth.gui; + +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; +import org.lwjgl.glfw.GLFW; +import technicianlp.reauth.session.SessionHelper; + +import java.util.Objects; + +public final class OfflineLoginScreen extends AbstractScreen { + + private TextFieldWidget username; + private ButtonWidget confirm; + + public OfflineLoginScreen() { + super("reauth.gui.title.offline"); + } + + @Override + public void init() { + super.init(); + + this.username = new TextFieldWidget(this.textRenderer, this.centerX - BUTTON_WIDTH / 2, this.centerY - 5, + BUTTON_WIDTH, 20, Text.translatable("reauth.gui.auth.username")); + this.username.setMaxLength(16); + this.username.setTextFieldFocused(true); + this.username.setText(Objects.requireNonNull(this.client).getSession().getUsername()); + this.addSelectableChild(this.username); + this.setInitialFocus(this.username); + + this.confirm = new ButtonWidget(this.centerX - BUTTON_WIDTH / 2, this.baseY + screenHeight - 42, + BUTTON_WIDTH, 20, Text.translatable("reauth.gui.button.username"), button -> this.performUsernameChange()); + this.addDrawableChild(this.confirm); + } + + @Override + public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) { + super.render(matrices, mouseX, mouseY, delta); + this.username.render(matrices, mouseX, mouseY, delta); + this.textRenderer.drawWithShadow(matrices, I18n.translate("reauth.gui.auth.username"), + this.centerX - (BUTTON_WIDTH / 2f), this.centerY - 15, 0xFFFFFFFF); + } + + @Override + public void tick() { + super.tick(); + this.confirm.active = SessionHelper.isValidOfflineUsername(this.username.getText()); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { + Element focus = this.getFocused(); + if (focus == this.username) { + this.performUsernameChange(); + return true; + } + } + return super.keyPressed(keyCode, scanCode, modifiers); + } + + /** + * Calls the to do the Login and handles Errors Closes the Screen if successful + */ + private void performUsernameChange() { + if (SessionHelper.isValidOfflineUsername(this.username.getText())) { + SessionHelper.setOfflineUsername(this.username.getText()); + this.requestClose(true); + } + } +} diff --git a/src/main/java/technicianlp/reauth/gui/PasswordFieldWidget.java b/src/main/java/technicianlp/reauth/gui/PasswordFieldWidget.java deleted file mode 100644 index 5220567..0000000 --- a/src/main/java/technicianlp/reauth/gui/PasswordFieldWidget.java +++ /dev/null @@ -1,162 +0,0 @@ -package technicianlp.reauth.gui; - -import net.minecraft.SharedConstants; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.gui.widget.TextFieldWidget; -import net.minecraft.text.Text; -import net.minecraft.util.Util; -import technicianlp.reauth.mixin.TextFieldWidgetMixin; - -import java.util.Arrays; - -final class PasswordFieldWidget extends TextFieldWidget { - - PasswordFieldWidget(TextRenderer renderer, int posx, int posy, int x, int y, Text name) { - super(renderer, posx, posy, x, y, name); - this.setMaxLength(512); - } - - private char[] password = new char[0]; - - /** - * Prevent Cut/Copy; actual logic handled by super - */ - @Override - public final boolean keyPressed(int keyCode, int scanCode, int modifiers) { - if (!this.isActive() || Screen.isCopy(keyCode) || Screen.isCut(keyCode)) - return false; - return super.keyPressed(keyCode, scanCode, modifiers); - } - - /** - * Vanilla filters out "§" therefore a custom filter is use (see {@link #isValidChar(char)}) to allow those - */ - @Override - public boolean charTyped(char chr, int keyCode) { - if (!this.isActive()) { - return false; - } else if (isValidChar(chr)) { - this.write(Character.toString(chr)); - return true; - } else { - return false; - } - } - - /** - * Modified version of {@link TextFieldWidget#write(String)} to allow for displayed text to differ and make the password be array based - */ - @Override - public final void write(String rawInput) { - int selectionEnd = getMixin().reauthGetSelectionEnd(); - int selStart = Math.min(this.getCursor(), selectionEnd); - int selEnd = Math.max(this.getCursor(), selectionEnd); - int selLength = selEnd - selStart; - - char[] input = stripInvalidChars(rawInput); - char[] newPW = new char[password.length - selLength + input.length]; - - if (password.length != 0 && selStart > 0) - System.arraycopy(password, 0, newPW, 0, Math.min(selStart, password.length)); - - System.arraycopy(input, 0, newPW, selStart, input.length); - - if (password.length != 0 && selEnd < password.length) - System.arraycopy(password, selEnd, newPW, selStart + input.length, password.length - selEnd); - - setPassword(newPW); - } - - /** - * Modified version of {@link TextFieldWidget#eraseCharacters(int)} to allow for displayed text to differ and make the password be array based - */ - @Override - public final void eraseCharacters(int characterOffset) { - if (password.length == 0) - return; - if (this.getMixin().reauthGetSelectionEnd() != this.getCursor()) { - this.write(""); - } else { - int cursor = Util.moveCursor(this.getText(), this.getCursor(), characterOffset); - int start = Math.min(cursor, this.getCursor()); - int end = Math.max(cursor, this.getCursor()); - - if(start != end) { - char[] newPW = new char[start + password.length - end]; - - if (start >= 0) - System.arraycopy(password, 0, newPW, 0, start); - - if (end < password.length) - System.arraycopy(password, end, newPW, start, password.length - end); - - setPassword(newPW); - this.setCursor(start); - } - } - } - - final char[] getPassword() { - char[] pw = new char[password.length]; - System.arraycopy(password, 0, pw, 0, password.length); - return pw; - } - - /** - * clear old password and update displayed Text - */ - final void setPassword(char[] password) { - Arrays.fill(this.password, 'f'); - this.password = password; - updateText(); - } - - /** - * Redirect Setter to {@link #setPassword(char[])} - */ - @Override - public final void setText(String textIn) { - setPassword(textIn.toCharArray()); - updateText(); - } - - /** - * Sets the actually displayed Text to all dots - */ - private void updateText() { - char[] chars = new char[password.length]; - Arrays.fill(chars, '\u25CF'); - super.setText(new String(chars)); - } - - /** - * Modified version of {@link SharedConstants#stripInvalidChars(String)} to allow SectionSign to be input into the field - */ - private char[] stripInvalidChars(String input) { - char[] out = new char[input.length()]; - int outInd = 0; - for (int i = 0; i < out.length; i++) { - char in = input.charAt(i); - if (isValidChar(in)) { - out[outInd++] = in; - } - } - char[] ret = new char[outInd]; - System.arraycopy(out, 0, ret, 0, outInd); - Arrays.fill(out, 'f'); - return ret; - } - - /** - * Modified version of {@link SharedConstants#isValidChar(char)} to allow SectionSign to be input into the field - */ - private boolean isValidChar(char in) { - return in == 0xa7 || SharedConstants.isValidChar(in); - } - - @SuppressWarnings("ConstantConditions") - private TextFieldWidgetMixin getMixin() { - return (TextFieldWidgetMixin)(Object) this; - } -} diff --git a/src/main/java/technicianlp/reauth/gui/SaveButton.java b/src/main/java/technicianlp/reauth/gui/SaveButton.java new file mode 100644 index 0000000..bab2001 --- /dev/null +++ b/src/main/java/technicianlp/reauth/gui/SaveButton.java @@ -0,0 +1,27 @@ +package technicianlp.reauth.gui; + +import net.minecraft.client.gui.widget.CheckboxWidget; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; + +public final class SaveButton extends CheckboxWidget { + + private final ITooltip tooltip; + + public SaveButton(int x, int y, Text message, ITooltip tooltip) { + super(x, y, 20, 20, message, false); + this.tooltip = tooltip; + } + + @Override + public void renderButton(MatrixStack matrices, int mouseX, int mouseY, float delta) { + super.renderButton(matrices, mouseX, mouseY, delta); + if (this.isHovered()) { + this.tooltip.onTooltip(this, matrices, mouseX, mouseY); + } + } + + public interface ITooltip { + void onTooltip(SaveButton button, MatrixStack matrixStack, int mouseX, int mouseY); + } +} diff --git a/src/main/java/technicianlp/reauth/integration/ClothConfigIntegration.java b/src/main/java/technicianlp/reauth/integration/ClothConfigIntegration.java deleted file mode 100644 index d2a8d23..0000000 --- a/src/main/java/technicianlp/reauth/integration/ClothConfigIntegration.java +++ /dev/null @@ -1,22 +0,0 @@ -package technicianlp.reauth.integration; - - -import me.sargunvohra.mcmods.autoconfig1u.AutoConfig; -import net.fabricmc.loader.api.FabricLoader; -import net.minecraft.client.gui.screen.Screen; -import technicianlp.reauth.Configuration; - -public final class ClothConfigIntegration { - - public static boolean isAvailable() { - return FabricLoader.getInstance().isModLoaded("cloth-config2"); - } - - public static Screen getConfigScreen(Screen parent) { - if(isAvailable()) { - return AutoConfig.getConfigScreen(Configuration.class, parent).get(); - } else { - return null; - } - } -} diff --git a/src/main/java/technicianlp/reauth/integration/ModMenuIntegration.java b/src/main/java/technicianlp/reauth/integration/ModMenuIntegration.java index b01a6a6..0f57489 100644 --- a/src/main/java/technicianlp/reauth/integration/ModMenuIntegration.java +++ b/src/main/java/technicianlp/reauth/integration/ModMenuIntegration.java @@ -1,13 +1,12 @@ package technicianlp.reauth.integration; -import io.github.prospector.modmenu.api.ConfigScreenFactory; -import io.github.prospector.modmenu.api.ModMenuApi; -import me.sargunvohra.mcmods.autoconfig1u.AutoConfig; -import technicianlp.reauth.Configuration; +import com.terraformersmc.modmenu.api.ConfigScreenFactory; +import com.terraformersmc.modmenu.api.ModMenuApi; +import technicianlp.reauth.gui.MainScreen; public final class ModMenuIntegration implements ModMenuApi { @Override public ConfigScreenFactory getModConfigScreenFactory() { - return parent -> AutoConfig.getConfigScreen(Configuration.class, parent).get(); + return MainScreen::new; } } diff --git a/src/main/java/technicianlp/reauth/mixin/ConnectScreenMixin.java b/src/main/java/technicianlp/reauth/mixin/ConnectScreenMixin.java index fcba67a..f69e73d 100644 --- a/src/main/java/technicianlp/reauth/mixin/ConnectScreenMixin.java +++ b/src/main/java/technicianlp/reauth/mixin/ConnectScreenMixin.java @@ -3,31 +3,15 @@ import net.minecraft.client.gui.screen.ConnectScreen; import net.minecraft.client.gui.screen.Screen; import net.minecraft.network.ClientConnection; -import net.minecraft.text.LiteralText; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import technicianlp.reauth.mixinUtil.ConnectScreenDuck; -import technicianlp.reauth.mixinUtil.DisconnectUtil; @Mixin(ConnectScreen.class) -public abstract class ConnectScreenMixin extends Screen implements ConnectScreenDuck { - protected ConnectScreenMixin() { - super(new LiteralText("Dummy Constructor")); - } +public interface ConnectScreenMixin { - @Override - @Accessor("connection") - public abstract ClientConnection reauthGetConnection(); + @Accessor + ClientConnection getConnection(); - @Override - @Accessor("parent") - public abstract Screen reauthGetParent(); - - @Inject(at = @At("TAIL"), method = "init()V") - private void reauthInit(CallbackInfo info) { - DisconnectUtil.setConnectScreen(this); - } + @Accessor + Screen getParent(); } diff --git a/src/main/java/technicianlp/reauth/mixin/DisconnectedScreenMixin.java b/src/main/java/technicianlp/reauth/mixin/DisconnectedScreenMixin.java index dd470f2..b23c04b 100644 --- a/src/main/java/technicianlp/reauth/mixin/DisconnectedScreenMixin.java +++ b/src/main/java/technicianlp/reauth/mixin/DisconnectedScreenMixin.java @@ -1,58 +1,12 @@ package technicianlp.reauth.mixin; -import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.screen.DisconnectedScreen; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.gui.widget.AbstractButtonWidget; -import net.minecraft.client.gui.widget.ButtonWidget; -import net.minecraft.text.LiteralText; import net.minecraft.text.Text; -import net.minecraft.text.TranslatableText; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import technicianlp.reauth.ReAuth; -import technicianlp.reauth.gui.AuthScreen; -import technicianlp.reauth.mixinUtil.DisconnectUtil; -import technicianlp.reauth.mixinUtil.DisconnectedScreenDuck; - -import static technicianlp.reauth.mixinUtil.DisconnectUtil.getTranslationKey; @Mixin(DisconnectedScreen.class) -public abstract class DisconnectedScreenMixin extends Screen implements DisconnectedScreenDuck { - protected DisconnectedScreenMixin() { - super(new LiteralText("Dummy Constructor")); - } - - @Override - @Accessor("reason") - public abstract Text reauthGetReason(); - - @Override - @Accessor("parent") - public abstract Screen reauthGetParent(); - - @Inject(at = @At("TAIL"), method = "init()V") - private void reauthInit(CallbackInfo info) { - if ("connect.failed".equals(getTranslationKey(this.getTitle()))) { - if (getTranslationKey(reauthGetReason()).startsWith("disconnect.loginFailed")) { - AbstractButtonWidget menu = this.buttons.get(0); - - String key = DisconnectUtil.canRetryLogin() ? "reauth.retry" : "reauth.retry.disabled"; - Text retryText = new TranslatableText(key, ReAuth.config.getProfile()); - ButtonWidget retryButton = new ButtonWidget(menu.x, menu.y + 25, 200, 20, retryText, b -> { - DisconnectUtil.retryLogin(); - }); - if (!DisconnectUtil.canRetryLogin()) { - retryButton.active = false; - } - this.addButton(retryButton); - this.addButton(new ButtonWidget(menu.x, menu.y + 50, 200, 20, new TranslatableText("reauth.open"), b -> { - MinecraftClient.getInstance().openScreen(new AuthScreen(reauthGetParent())); - })); - } - } - } +public interface DisconnectedScreenMixin { + @Accessor + Text getReason(); } diff --git a/src/main/java/technicianlp/reauth/mixin/MinecraftClientMixin.java b/src/main/java/technicianlp/reauth/mixin/MinecraftClientMixin.java index 2be529e..5ffbf93 100644 --- a/src/main/java/technicianlp/reauth/mixin/MinecraftClientMixin.java +++ b/src/main/java/technicianlp/reauth/mixin/MinecraftClientMixin.java @@ -1,6 +1,8 @@ package technicianlp.reauth.mixin; +import com.mojang.authlib.minecraft.UserApiService; import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.SocialInteractionsManager; import net.minecraft.client.util.Session; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mutable; @@ -8,8 +10,15 @@ @Mixin(MinecraftClient.class) public interface MinecraftClientMixin { + @Accessor + @Mutable + void setSession(Session session); + + @Accessor + @Mutable + void setUserApiService(UserApiService userApiService); - @Accessor("session") + @Accessor @Mutable - void reauthSetSession(Session session); + void setSocialInteractionsManager(SocialInteractionsManager manager); } diff --git a/src/main/java/technicianlp/reauth/mixin/MultiplayerScreenMixin.java b/src/main/java/technicianlp/reauth/mixin/MultiplayerScreenMixin.java deleted file mode 100644 index b75ba7c..0000000 --- a/src/main/java/technicianlp/reauth/mixin/MultiplayerScreenMixin.java +++ /dev/null @@ -1,39 +0,0 @@ -package technicianlp.reauth.mixin; - -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.gui.screen.multiplayer.MultiplayerScreen; -import net.minecraft.client.gui.widget.ButtonWidget; -import net.minecraft.client.resource.language.I18n; -import net.minecraft.client.util.math.MatrixStack; -import net.minecraft.text.LiteralText; -import net.minecraft.text.TranslatableText; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import technicianlp.reauth.AuthHelper; -import technicianlp.reauth.ReAuth; -import technicianlp.reauth.gui.AuthScreen; - -@Mixin(MultiplayerScreen.class) -public class MultiplayerScreenMixin extends Screen { - protected MultiplayerScreenMixin() { - super(new LiteralText("Dummy Constructor")); - } - - @Inject(at = @At("TAIL"), method = "init()V") - private void reauthInit(CallbackInfo info) { - if(Screen.hasShiftDown()) { - ReAuth.auth.getSessionStatus(true); - } - addButton(new ButtonWidget(5, 5, 100, 20, new TranslatableText("reauth.gui.button"), - b -> MinecraftClient.getInstance().openScreen(new AuthScreen(this)))); - } - - @Inject(at = @At("TAIL"), method = "render(Lnet/minecraft/client/util/math/MatrixStack;IIF)V") - private void reauthRender(MatrixStack matrices, int mouseX, int mouseY, float delta, CallbackInfo info) { - AuthHelper.SessionStatus state = ReAuth.auth.getSessionStatus(false); - textRenderer.draw(matrices, I18n.translate(state.getTranslationKey()), 110, 10, 0xFFFFFFFF); - } -} diff --git a/src/main/java/technicianlp/reauth/mixin/SplashTextResourceSupplierMixin.java b/src/main/java/technicianlp/reauth/mixin/SplashTextResourceSupplierMixin.java new file mode 100644 index 0000000..97bac4b --- /dev/null +++ b/src/main/java/technicianlp/reauth/mixin/SplashTextResourceSupplierMixin.java @@ -0,0 +1,14 @@ +package technicianlp.reauth.mixin; + +import net.minecraft.client.resource.SplashTextResourceSupplier; +import net.minecraft.client.util.Session; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(SplashTextResourceSupplier.class) +public interface SplashTextResourceSupplierMixin { + @Accessor + @Mutable + void setSession(Session session); +} diff --git a/src/main/java/technicianlp/reauth/mixin/TextFieldWidgetMixin.java b/src/main/java/technicianlp/reauth/mixin/TextFieldWidgetMixin.java deleted file mode 100644 index 12f2eda..0000000 --- a/src/main/java/technicianlp/reauth/mixin/TextFieldWidgetMixin.java +++ /dev/null @@ -1,12 +0,0 @@ -package technicianlp.reauth.mixin; - -import net.minecraft.client.gui.widget.TextFieldWidget; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.gen.Accessor; - -@Mixin(TextFieldWidget.class) -public interface TextFieldWidgetMixin { - - @Accessor("selectionEnd") - int reauthGetSelectionEnd(); -} diff --git a/src/main/java/technicianlp/reauth/mixinUtil/ConnectScreenDuck.java b/src/main/java/technicianlp/reauth/mixinUtil/ConnectScreenDuck.java deleted file mode 100644 index c610f6c..0000000 --- a/src/main/java/technicianlp/reauth/mixinUtil/ConnectScreenDuck.java +++ /dev/null @@ -1,10 +0,0 @@ -package technicianlp.reauth.mixinUtil; - -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.network.ClientConnection; - -public interface ConnectScreenDuck { - ClientConnection reauthGetConnection(); - - Screen reauthGetParent(); -} diff --git a/src/main/java/technicianlp/reauth/mixinUtil/DisconnectUtil.java b/src/main/java/technicianlp/reauth/mixinUtil/DisconnectUtil.java deleted file mode 100644 index 1d86161..0000000 --- a/src/main/java/technicianlp/reauth/mixinUtil/DisconnectUtil.java +++ /dev/null @@ -1,50 +0,0 @@ -package technicianlp.reauth.mixinUtil; - -import com.mojang.authlib.exceptions.AuthenticationException; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gui.screen.ConnectScreen; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.resource.language.I18n; -import net.minecraft.text.TranslatableText; -import technicianlp.reauth.ReAuth; -import technicianlp.reauth.gui.AuthScreen; - -import java.net.InetSocketAddress; -import java.net.SocketAddress; - -public class DisconnectUtil { - private static ConnectScreenDuck screen; - - public static String getTranslationKey(Object component) { - if (component instanceof TranslatableText) { - return ((TranslatableText) component).getKey(); - } - return ""; - } - - public static void setConnectScreen(ConnectScreenDuck screen) { - DisconnectUtil.screen = screen; - } - - public static boolean canRetryLogin() { - return screen != null && ReAuth.config.hasCredentials(); - } - - public static void retryLogin() { - try { - ReAuth.auth.login(ReAuth.config.getUsername(), ReAuth.config.getPassword(), true); - if (screen != null) { - SocketAddress add = screen.reauthGetConnection().getAddress(); - if (add instanceof InetSocketAddress) { - InetSocketAddress address = (InetSocketAddress) add; - MinecraftClient client = MinecraftClient.getInstance(); - client.openScreen(new ConnectScreen(screen.reauthGetParent(), client, address.getHostString(), address.getPort())); - } - } - } catch (AuthenticationException exception) { - ReAuth.log.error("Login failed:", exception); - Screen login = new AuthScreen(screen.reauthGetParent(), I18n.translate("reauth.login.fail", exception.getMessage())); - MinecraftClient.getInstance().openScreen(login); - } - } -} diff --git a/src/main/java/technicianlp/reauth/mixinUtil/DisconnectedScreenDuck.java b/src/main/java/technicianlp/reauth/mixinUtil/DisconnectedScreenDuck.java deleted file mode 100644 index 18ea4ca..0000000 --- a/src/main/java/technicianlp/reauth/mixinUtil/DisconnectedScreenDuck.java +++ /dev/null @@ -1,11 +0,0 @@ -package technicianlp.reauth.mixinUtil; - -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.text.Text; - - -public interface DisconnectedScreenDuck { - Text reauthGetReason(); - - Screen reauthGetParent(); -} diff --git a/src/main/java/technicianlp/reauth/session/SessionHelper.java b/src/main/java/technicianlp/reauth/session/SessionHelper.java new file mode 100644 index 0000000..60e3d5e --- /dev/null +++ b/src/main/java/technicianlp/reauth/session/SessionHelper.java @@ -0,0 +1,104 @@ +package technicianlp.reauth.session; + +import com.mojang.authlib.exceptions.AuthenticationException; +import com.mojang.authlib.minecraft.UserApiService; +import com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService; +import com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.RunArgs; +import net.minecraft.client.network.SocialInteractionsManager; +import net.minecraft.client.resource.SplashTextResourceSupplier; +import net.minecraft.client.util.Session; +import technicianlp.reauth.ReAuth; +import technicianlp.reauth.authentication.SessionData; +import technicianlp.reauth.mixin.MinecraftClientMixin; +import technicianlp.reauth.mixin.SplashTextResourceSupplierMixin; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; + +public enum SessionHelper { + ; + + private static final Pattern usernamePattern = Pattern.compile("\\w{2,16}"); + + /** + * construct a {@link SessionData} for the given offline username + */ + public static void setOfflineUsername(String username) { + UUID uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + username).getBytes(StandardCharsets.UTF_8)); + setSession(new SessionData(username, uuid.toString(), "invalid", "legacy"), false); + } + + /** + * Set the Session and update dependant fields + */ + public static void setSession(SessionData data) { + setSession(data, true); + } + + /** + * Set the Session and update dependant fields + *

    + *
  • Clear ProfileProperties and repopulate them + *
  • Recreate {@link UserApiService}, using logic from + * {@link MinecraftClient#createUserApiService(YggdrasilAuthenticationService, RunArgs)} + *
  • Recreate {@link SocialInteractionsManager} with the new SocialInteractionsService + *
  • Update {@link SplashTextResourceSupplier#session} + *
+ */ + private static void setSession(SessionData data, boolean online) { + try { + MinecraftClient minecraft = MinecraftClient.getInstance(); + + Session session = new Session(data.username(), data.uuid(), data.accessToken(), Optional.empty(), + Optional.empty(), Session.AccountType.byName(data.type())); + + ((MinecraftClientMixin) minecraft).setSession(session); + SessionChecker.invalidate(); + + // Update things depending on the Session. + // TODO keep updated across versions + + // Clear ProfileProperties and repopulate them + minecraft.getSessionProperties().clear(); + minecraft.getSessionProperties(); + // UserProperties are unused + + // Recreate UserApiService + UserApiService userApiService = null; + if (online) { + YggdrasilMinecraftSessionService sessionService = + (YggdrasilMinecraftSessionService) minecraft.getSessionService(); + YggdrasilAuthenticationService authService = sessionService.getAuthenticationService(); + try { + userApiService = authService.createUserApiService(session.getAccessToken()); + } catch (AuthenticationException authException) { + ReAuth.log.error("Failed to create UserApiService", authException); + } + } + if (userApiService == null) { + userApiService = UserApiService.OFFLINE; + } + ((MinecraftClientMixin) minecraft).setUserApiService(userApiService); + + // Recreate FilterManager + SocialInteractionsManager socialManager = new SocialInteractionsManager(minecraft, userApiService); + ((MinecraftClientMixin) minecraft).setSocialInteractionsManager(socialManager); + + // Update Splashes session + ((SplashTextResourceSupplierMixin) minecraft.getSplashTextLoader()).setSession(session); + } catch (Exception e) { + ReAuth.log.error("Failed to update Session", e); + } + } + + /** + * checks the username to match the offline username regex + */ + public static boolean isValidOfflineUsername(String username) { + return usernamePattern.matcher(username).matches(); + } +} diff --git a/src/main/resources/assets/reauth/lang/en_us.json b/src/main/resources/assets/reauth/lang/en_us.json index 579ce1d..52bfdf5 100644 --- a/src/main/resources/assets/reauth/lang/en_us.json +++ b/src/main/resources/assets/reauth/lang/en_us.json @@ -1,6 +1,9 @@ { "reauth.open": "Open ReAuth", "reauth.gui.auth.title": "ReAuthentication Screen", + "reauth.gui.title.main": "ReAuth", + "reauth.gui.title.flow": "", + "reauth.gui.title.offline": "Choose a Username", "reauth.gui.auth.username": "Username", "reauth.gui.auth.password": "Password", "reauth.gui.auth.checkbox": "Save Password to Config (WARNING: SECURITY RISK!)", @@ -13,14 +16,56 @@ "reauth.gui.auth.text2": "Password:", "reauth.gui.auth.update": "§9Update available: %s", "reauth.gui.button": "ReAuth Login", + "reauth.gui.button.save": "Save Profile to Config", + "reauth.gui.button.save.tooltip": "WARNING: Anyone with access to your config file may be able to extract and decrypt the stored profile.", + "reauth.gui.close": "X", + "reauth.gui.noProfile": "Login with saved Profile", + "reauth.gui.profile": "Login as %s", + "reauth.gui.text.profile": "Login using saved Profile", + "reauth.gui.text.microsoft": "Login using a Xbox Account", + "reauth.gui.text.offline": "Choose an offline Username", + "reauth.gui.button.authcode": "This Device", + "reauth.gui.button.devicecode": "Any Device", + "reauth.gui.button.offline": "Choose Username", + "reauth.gui.button.username": "Set Username", "reauth.status.valid": "Online: §l§a✔", "reauth.status.invalid": "Online: §l§c✘", "reauth.status.unknown": "Online: §l§7?", + "reauth.status.refreshing": "Online: §l§b?", + "reauth.status.error": "Online: §l§c?", "reauth.login.success": "§aLogin successful!", "reauth.login.fail": "§4Login failed: %s", "reauth.login.error": "§4Error: %s", "reauth.retry": "Login as %s and retry", - "reauth.retry.disabled": "Login and retry not available", + "reauth.retry.disabled": "Login with saved Profile and retry", + "reauth.msauth.code.success": "Authentication with Microsoft was successful", + "reauth.msauth.code.success.close": "You may now close this page", + "reauth.msauth.code.fail.cancelled": "Authentication with Microsoft was cancelled at your request", + "reauth.msauth.code.fail.server": "Authentication with Microsoft is temporarily unavailable", + "reauth.msauth.code.fail.unknown": "Authentication with Microsoft failed", + "reauth.msauth.code.retry": "Please click here to try again", + "reauth.msauth.code.error.http.200": "Error: This is not an error!?", + "reauth.msauth.code.error.http.400": "Error: Authentication failed", + "reauth.msauth.code.error.http.404": "Error: This resource does not exist", + "reauth.msauth.code.error.http.405": "Error: Incorrect request method", + "reauth.msauth.code.error.http.415": "Error: Authentication data has invalid format", + "reauth.msauth.code.error.http.501": "Error: Unknown request method", + "reauth.msauth.step.initial": "Starting authentication Process", + "reauth.msauth.step.finished": "Login successful", + "reauth.msauth.step.profile": "Saving Profile", + "reauth.msauth.step.failed": "Login failed - Please try again", + "reauth.msauth.step.crypto": "Initializing Profile encryption", + "reauth.msauth.step.yggdrasil": "Authenticating with Mojang", + "reauth.msauth.step.microsoft.code.await": "Please check your Browser to login", + "reauth.msauth.step.microsoft.code.redeem": "Authenticating with Microsoft", + "reauth.msauth.step.microsoft.device.request": "Setting up Authentication with Microsoft", + "reauth.msauth.step.microsoft.device.poll": "Please enter code\n$%2$s\non %1$s", + "reauth.msauth.step.microsoft.refresh": "Refreshing Authentication with Microsoft", + "reauth.msauth.step.xbox": "Authenticating with Xbox Live", + "reauth.msauth.step.xsts": "Authenticating with Xbox Live Token Service", + "reauth.msauth.step.mojang": "Authenticating with Mojang", + "reauth.msauth.step.fetch": "Retrieving Profile information", + "reauth.msauth.button.browser": "Open Login-Page in Browser", "text.autoconfig.../reauth.title": "ReAuth Config", "text.autoconfig.../reauth.option.credentials.salt": "Salt", "text.autoconfig.../reauth.option.credentials.salt.@Tooltip[0]": "One of the values required to decrypt the credentials", diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 6d93600..abecc47 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -2,22 +2,22 @@ "schemaVersion": 1, "id": "reauth", "version": "${version}", - "name": "ReAuth", "description": "A Mod to renew your Session", "authors": [ - "TechnicianLP" + "TechnicianLP", + "NgoKimPhu" ], "contact": { - "homepage": "https://github.com/TechnicianLP/ReAuth", - "sources": "https://github.com/TechnicianLP/ReAuth", - "issues": "https://github.com/TechnicianLP/ReAuth/issues" + "homepage": "https://github.com/NgoKimPhu/ReAuth", + "sources": "https://github.com/NgoKimPhu/ReAuth", + "issues": "https://github.com/NgoKimPhu/ReAuth/issues" }, "license": "All rights reserved", - + "icon": "reauth/icon.png", "environment": "client", "entrypoints": { - "main": [ + "client": [ "technicianlp.reauth.ReAuth" ], "modmenu": [ @@ -27,16 +27,16 @@ "mixins": [ "reauth.mixins.json" ], - "depends": { - "fabricloader": ">=0.7.4", + "fabricloader": ">=0.14.9", "fabric": "*", - "minecraft": "1.16.*", - "autoconfig1u": "*" + "minecraft": "~1.19", + "java": ">=17" }, "recommends": { - "cloth-config2": "*", "modmenu": "*" }, - "custom": { "modmenu:clientsideOnly": true } + "custom": { + "modmenu:clientsideOnly": true + } } diff --git a/src/main/resources/reauth.mixins.json b/src/main/resources/reauth.mixins.json index c35c10d..d56cdcb 100644 --- a/src/main/resources/reauth.mixins.json +++ b/src/main/resources/reauth.mixins.json @@ -2,14 +2,12 @@ "required": true, "minVersion": "0.8", "package": "technicianlp.reauth.mixin", - "compatibilityLevel": "JAVA_8", - "mixins": [], + "compatibilityLevel": "JAVA_17", "client": [ - "MultiplayerScreenMixin", - "DisconnectedScreenMixin", "ConnectScreenMixin", + "DisconnectedScreenMixin", "MinecraftClientMixin", - "TextFieldWidgetMixin" + "SplashTextResourceSupplierMixin" ], "injectors": { "defaultRequire": 1