feat(microsoft): WIP: use device code grant flow instead.

This commit is contained in:
huanghongxun 2021-10-23 00:01:55 +08:00
parent 843b29eff0
commit 5a9e8683e4
12 changed files with 456 additions and 198 deletions

View File

@ -19,7 +19,9 @@ package org.jackhuang.hmcl.game;
import fi.iki.elonen.NanoHTTPD;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftService;
import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.StringUtils;
@ -39,7 +41,7 @@ import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class MicrosoftAuthenticationServer extends NanoHTTPD implements MicrosoftService.OAuthSession {
public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
private final int port;
private final CompletableFuture<String> future = new CompletableFuture<>();
@ -47,7 +49,7 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
private String idToken;
private MicrosoftAuthenticationServer(int port) {
private OAuthServer(int port) {
super(port);
this.port = port;
@ -102,7 +104,7 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
String html;
try {
html = IOUtils.readFullyAsString(MicrosoftAuthenticationServer.class.getResourceAsStream("/assets/microsoft_auth.html"), StandardCharsets.UTF_8)
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"), StandardCharsets.UTF_8)
.replace("%close-page%", i18n("account.methods.microsoft.close_page"));
} catch (IOException e) {
Logging.LOG.log(Level.SEVERE, "Failed to load html");
@ -119,10 +121,12 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
return newFixedLengthResponse(Response.Status.OK, "text/html; charset=UTF-8", html);
}
public static class Factory implements MicrosoftService.OAuthCallback {
public static class Factory implements OAuth.Callback {
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowser = new EventManager<>();
@Override
public MicrosoftService.OAuthSession startServer() throws IOException, AuthenticationException {
public OAuth.Session startServer() throws IOException, AuthenticationException {
if (StringUtils.isBlank(getClientId())) {
throw new MicrosoftAuthenticationNotSupportedException();
}
@ -130,7 +134,7 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
IOException exception = null;
for (int port : new int[]{29111, 29112, 29113, 29114, 29115}) {
try {
MicrosoftAuthenticationServer server = new MicrosoftAuthenticationServer(port);
OAuthServer server = new OAuthServer(port);
server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, true);
return server;
} catch (IOException e) {
@ -140,10 +144,17 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
throw exception;
}
@Override
public void grantDeviceCode(String userCode, String verificationURI) {
onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI));
}
@Override
public void openBrowser(String url) throws IOException {
lastlyOpenedURL = url;
FXUtils.openLink(url);
onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url));
}
@Override
@ -160,6 +171,38 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
}
public static class GrantDeviceCodeEvent extends Event {
private final String userCode;
private final String verificationUri;
public GrantDeviceCodeEvent(Object source, String userCode, String verificationUri) {
super(source);
this.userCode = userCode;
this.verificationUri = verificationUri;
}
public String getUserCode() {
return userCode;
}
public String getVerificationUri() {
return verificationUri;
}
}
public static class OpenBrowserEvent extends Event {
private final String url;
public OpenBrowserEvent(Object source, String url) {
super(source);
this.url = url;
}
public String getUrl() {
return url;
}
}
public static class MicrosoftAuthenticationNotSupportedException extends AuthenticationException {
}
}

View File

@ -34,7 +34,7 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory;
import org.jackhuang.hmcl.game.MicrosoftAuthenticationServer;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
@ -75,12 +75,12 @@ public final class Accounts {
}
}
public static final MicrosoftService.OAuthCallback MICROSOFT_OAUTH_CALLBACK = new MicrosoftAuthenticationServer.Factory();
public static final OAuthServer.Factory OAUTH_CALLBACK = new OAuthServer.Factory();
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
public static final YggdrasilAccountFactory FACTORY_MOJANG = YggdrasilAccountFactory.MOJANG;
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(MICROSOFT_OAUTH_CALLBACK));
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
public static final List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MOJANG, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
// ==== login type / account factory mapping ====
@ -374,7 +374,7 @@ public final class Accounts {
return i18n("account.methods.microsoft.error.no_character");
} else if (exception instanceof MicrosoftService.NoXuiException) {
return i18n("account.methods.microsoft.error.add_family_probably");
} else if (exception instanceof MicrosoftAuthenticationServer.MicrosoftAuthenticationNotSupportedException) {
} else if (exception instanceof OAuthServer.MicrosoftAuthenticationNotSupportedException) {
return i18n("account.methods.microsoft.snapshot");
} else if (exception instanceof OAuthAccount.WrongAccountException) {
return i18n("account.failed.wrong_account");

View File

@ -20,7 +20,7 @@ package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftService;
import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@ -57,11 +57,11 @@ public final class HMCLAccounts {
String scope = "openid offline_access";
return Task.supplyAsync(() -> {
MicrosoftService.OAuthSession session = Accounts.MICROSOFT_OAUTH_CALLBACK.startServer();
Accounts.MICROSOFT_OAUTH_CALLBACK.openBrowser(NetworkUtils.withQuery(
OAuth.Session session = Accounts.OAUTH_CALLBACK.startServer();
Accounts.OAUTH_CALLBACK.openBrowser(NetworkUtils.withQuery(
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
mapOf(
pair("client_id", Accounts.MICROSOFT_OAUTH_CALLBACK.getClientId()),
pair("client_id", Accounts.OAUTH_CALLBACK.getClientId()),
pair("response_type", "id_token code"),
pair("response_mode", "form_post"),
pair("scope", scope),
@ -72,12 +72,12 @@ public final class HMCLAccounts {
// Authorization Code -> Token
String responseText = HttpRequest.POST("https://login.microsoftonline.com/common/oauth2/v2.0/token")
.form(mapOf(pair("client_id", Accounts.MICROSOFT_OAUTH_CALLBACK.getClientId()), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", Accounts.MICROSOFT_OAUTH_CALLBACK.getClientSecret()),
.form(mapOf(pair("client_id", Accounts.OAUTH_CALLBACK.getClientId()), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", Accounts.OAUTH_CALLBACK.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", scope)))
.getString();
MicrosoftService.LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText,
MicrosoftService.LiveAuthorizationResponse.class);
OAuth.AuthorizationResponse response = JsonUtils.fromNonNullJson(responseText,
OAuth.AuthorizationResponse.class);
HMCLAccountProfile profile = HttpRequest.GET("https://hmcl.huangyuhui.net/api/user")
.header("Token-Type", response.tokenType)

View File

@ -43,7 +43,7 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.GameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.game.MicrosoftAuthenticationServer;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.Theme;
@ -267,8 +267,8 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
? i18n("account.methods.microsoft.manual")
: i18n("account.methods.microsoft.hint")));
hintPane.setOnMouseClicked(e -> {
if (logging.get() && MicrosoftAuthenticationServer.lastlyOpenedURL != null) {
FXUtils.copyText(MicrosoftAuthenticationServer.lastlyOpenedURL);
if (logging.get() && OAuthServer.lastlyOpenedURL != null) {
FXUtils.copyText(OAuthServer.lastlyOpenedURL);
}
});

View File

@ -1,7 +1,7 @@
package org.jackhuang.hmcl.ui.account;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.control.Label;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
@ -9,11 +9,12 @@ import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.auth.OAuthAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.game.MicrosoftAuthenticationServer;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
@ -27,7 +28,9 @@ public class OAuthAccountLoginDialog extends DialogPane {
private final OAuthAccount account;
private final Consumer<AuthInfo> success;
private final Runnable failed;
private final BooleanProperty logging = new SimpleBooleanProperty();
private final ObjectProperty<OAuthServer.GrantDeviceCodeEvent> deviceCode = new SimpleObjectProperty<>();
private final WeakListenerHolder holder = new WeakListenerHolder();
public OAuthAccountLoginDialog(OAuthAccount account, Consumer<AuthInfo> success, Runnable failed) {
this.account = account;
@ -40,13 +43,13 @@ public class OAuthAccountLoginDialog extends DialogPane {
Label usernameLabel = new Label(account.getUsername());
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.textProperty().bind(BindingMapping.of(logging).map(logging ->
logging
? i18n("account.methods.microsoft.manual")
hintPane.textProperty().bind(BindingMapping.of(deviceCode).map(deviceCode ->
deviceCode != null
? i18n("account.methods.microsoft.manual", deviceCode.getUserCode())
: i18n("account.methods.microsoft.hint")));
hintPane.setOnMouseClicked(e -> {
if (logging.get() && MicrosoftAuthenticationServer.lastlyOpenedURL != null) {
FXUtils.copyText(MicrosoftAuthenticationServer.lastlyOpenedURL);
if (deviceCode.get() != null) {
FXUtils.copyText(deviceCode.get().getVerificationUri());
}
});
@ -62,15 +65,19 @@ public class OAuthAccountLoginDialog extends DialogPane {
vbox.getChildren().setAll(usernameLabel, hintPane, box);
setBody(vbox);
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(this::onGrantDeviceCode));
}
private void onGrantDeviceCode(OAuthServer.GrantDeviceCodeEvent event) {
deviceCode.set(event);
}
@Override
protected void onAccept() {
setLoading();
logging.set(true);
Task.supplyAsync(account::logInWhenCredentialsExpired)
.whenComplete(Schedulers.javafx(), (authInfo, exception) -> {
logging.set(false);
if (exception == null) {
success.accept(authInfo);
onSuccess();

View File

@ -31,7 +31,7 @@ import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.MicrosoftAuthenticationServer;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.HMCLAccounts;
import org.jackhuang.hmcl.setting.Theme;
@ -246,8 +246,8 @@ public class FeedbackPage extends VBox implements PageAware {
? i18n("account.methods.microsoft.manual")
: i18n("account.methods.microsoft.hint")));
hintPane.setOnMouseClicked(e -> {
if (logging.get() && MicrosoftAuthenticationServer.lastlyOpenedURL != null) {
FXUtils.copyText(MicrosoftAuthenticationServer.lastlyOpenedURL);
if (logging.get() && OAuthServer.lastlyOpenedURL != null) {
FXUtils.copyText(OAuthServer.lastlyOpenedURL);
}
});
vbox.getChildren().setAll(hintPane);

View File

@ -96,8 +96,8 @@ account.methods.microsoft.error.missing_xbox_account=Your Microsoft account is n
account.methods.microsoft.error.no_character=Account is missing a Minecraft Java profile. While the Microsoft account is valid, it does not own the game.
account.methods.microsoft.error.unknown=Failed to log in. Microsoft respond with error code %d.
account.methods.microsoft.logging_in=Logging in...
account.methods.microsoft.hint=You should click "login" button and continue login process in newly opened browser window.
account.methods.microsoft.manual=After clicking "login" button, you should finish authorization in the newly opened browser window. If the browser window failed to show, you can click here to copy the URL, and manually open it in your browser.
account.methods.microsoft.hint=You should click "login" button, paste the device code shown here later and continue login process in newly opened browser window.
account.methods.microsoft.manual=Your device code is <b>%s</b>. After clicking "login" button, you should finish authorization in the newly opened browser window. If the browser window failed to show, you can click here to copy the URL, and manually open it in your browser.
account.methods.microsoft.profile=Account Profile...
account.methods.microsoft.snapshot=HMCL Snapshot version does not support Microsoft login.
account.methods.microsoft.waiting_browser=Waiting for authorization in opened browser window...

View File

@ -96,8 +96,8 @@ account.methods.microsoft.error.missing_xbox_account=你的微軟帳號尚未關
account.methods.microsoft.error.no_character=該帳號沒有包含 Minecraft Java 版購買記錄
account.methods.microsoft.error.unknown=登入失敗,錯誤碼:%d
account.methods.microsoft.logging_in=登入中...
account.methods.microsoft.hint=您需要點擊登按鈕,並在新打開的瀏覽器窗口中完成登入。
account.methods.microsoft.manual=若登入頁面未能打開,您可以點擊此處複製連結,並手動在瀏覽器中打開網頁。
account.methods.microsoft.hint=您需要點擊登按鈕,並在新打開的瀏覽器窗口中完成登錄,並輸待會在此處顯示的設備代碼
account.methods.microsoft.manual=設備代碼為:<b>%s</b>。若登錄頁面未能打開,您可以點擊此處複製連結,並手動在瀏覽器中打開網頁。
account.methods.microsoft.profile=帳戶設置頁
account.methods.microsoft.snapshot=HMCL 快照版不支持微软登录
account.methods.microsoft.waiting_browser=等待在新打開的瀏覽器窗口中完成登入...

View File

@ -96,8 +96,8 @@ account.methods.microsoft.error.missing_xbox_account=你的微软帐户尚未关
account.methods.microsoft.error.no_character=该帐户没有包含 Minecraft Java 版购买记录
account.methods.microsoft.error.unknown=登录失败,错误码:%d
account.methods.microsoft.logging_in=登录中...
account.methods.microsoft.hint=您需要点击登录按钮,并在新打开的浏览器窗口中完成登录
account.methods.microsoft.manual=若登录页面未能打开,您可以点击此处复制链接,并手动在浏览器中打开网页。
account.methods.microsoft.hint=您需要点击登录按钮,并在新打开的浏览器窗口中完成登录,并输入待会在此处显示的设备代码
account.methods.microsoft.manual=设备代码为:<b>%s</b>。若登录页面未能打开,您可以点击此处复制链接,并手动在浏览器中打开网页。
account.methods.microsoft.profile=帐户设置页
account.methods.microsoft.snapshot=HMCL 快照版不支持微软登录
account.methods.microsoft.waiting_browser=等待在新打开的浏览器窗口中完成登录...

View File

@ -0,0 +1,350 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.auth;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
public class OAuth {
public static final OAuth MICROSOFT = new OAuth(
"https://login.live.com/oauth20_authorize.srf",
"https://login.live.com/oauth20_token.srf",
"https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode",
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token");
private final String authorizationURL;
private final String accessTokenURL;
private final String deviceCodeURL;
private final String tokenURL;
public OAuth(String authorizationURL, String accessTokenURL, String deviceCodeURL, String tokenURL) {
this.authorizationURL = authorizationURL;
this.accessTokenURL = accessTokenURL;
this.deviceCodeURL = deviceCodeURL;
this.tokenURL = tokenURL;
}
public Result authenticate(GrantFlow grantFlow, Options options) throws AuthenticationException {
try {
switch (grantFlow) {
case AUTHORIZATION_CODE:
return authenticateAuthorizationCode(options);
case DEVICE:
return authenticateDevice(options);
default:
throw new UnsupportedOperationException("grant flow " + grantFlow);
}
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (InterruptedException e) {
throw new NoSelectedCharacterException();
} catch (ExecutionException e) {
if (e.getCause() instanceof InterruptedException) {
throw new NoSelectedCharacterException();
} else {
throw new ServerDisconnectException(e);
}
} catch (JsonParseException e) {
throw new ServerResponseMalformedException(e);
}
}
private Result authenticateAuthorizationCode(Options options) throws IOException, InterruptedException, JsonParseException, ExecutionException, AuthenticationException {
Session session = options.callback.startServer();
options.callback.openBrowser(NetworkUtils.withQuery(authorizationURL,
mapOf(pair("client_id", options.callback.getClientId()), pair("response_type", "code"),
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope),
pair("prompt", "select_account"))));
String code = session.waitFor();
// Authorization Code -> Token
AuthorizationResponse response = HttpRequest.POST(accessTokenURL)
.form(pair("client_id", options.callback.getClientId()), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", options.callback.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope))
.ignoreHttpCode()
.getJson(AuthorizationResponse.class);
handleErrorResponse(response);
return new Result(response.accessToken, response.refreshToken);
}
private Result authenticateDevice(Options options) throws IOException, InterruptedException, JsonParseException, AuthenticationException {
DeviceTokenResponse deviceTokenResponse = HttpRequest.POST(deviceCodeURL)
.form(pair("client_id", options.callback.getClientId()), pair("scope", options.scope))
.ignoreHttpCode()
.getJson(DeviceTokenResponse.class);
options.callback.grantDeviceCode(deviceTokenResponse.deviceCode, deviceTokenResponse.verificationURI);
// Microsoft OAuth Flow
options.callback.openBrowser(deviceTokenResponse.verificationURI);
long startTime = System.nanoTime();
int interval = deviceTokenResponse.interval;
while (true) {
Thread.sleep(Math.max(interval, 1));
// We stop waiting if user does not respond our authentication request in 15 minutes.
long estimatedTime = System.nanoTime() - startTime;
if (TimeUnit.MINUTES.convert(estimatedTime, TimeUnit.SECONDS) >= Math.min(deviceTokenResponse.expiresIn, 900)) {
throw new NoSelectedCharacterException();
}
TokenResponse tokenResponse = HttpRequest.POST(tokenURL)
.form(
pair("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
pair("code", deviceTokenResponse.deviceCode),
pair("client_id", options.callback.getClientId()))
.getJson(TokenResponse.class);
if ("authorization_pending".equals(tokenResponse.error)) {
continue;
}
if ("expired_token".equals(tokenResponse.error)) {
throw new NoSelectedCharacterException();
}
if ("slow_down".equals(tokenResponse.error)) {
interval += 5;
continue;
}
return new Result(tokenResponse.accessToken, tokenResponse.refreshToken);
}
}
public Result refresh(String refreshToken, Options options) throws AuthenticationException {
try {
RefreshResponse response = HttpRequest.POST(accessTokenURL)
.form(pair("client_id", options.callback.getClientId()),
pair("client_secret", options.callback.getClientSecret()),
pair("refresh_token", refreshToken),
pair("grant_type", "refresh_token"))
.accept("application/json")
.ignoreHttpCode()
.getJson(RefreshResponse.class);
handleErrorResponse(response);
return new Result(response.accessToken, response.refreshToken);
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
throw new ServerResponseMalformedException(e);
}
}
private static void handleErrorResponse(ErrorResponse response) throws AuthenticationException {
if (response.error == null || response.errorDescription == null) {
return;
}
switch (response.error) {
case "invalid_grant":
if (response.errorDescription.contains("The user must sign in again and if needed grant the client application access to the requested scope")) {
throw new CredentialExpiredException();
}
break;
}
throw new RemoteAuthenticationException(response.error, response.errorDescription, "");
}
public static class Options {
private String userAgent;
private final String scope;
private final Callback callback;
public Options(String scope, Callback callback) {
this.scope = scope;
this.callback = callback;
}
public Options setUserAgent(String userAgent) {
this.userAgent = userAgent;
return this;
}
}
public interface Session {
String getRedirectURI();
/**
* Wait for authentication
*
* @return authentication code
* @throws InterruptedException if interrupted
* @throws ExecutionException if an I/O error occurred.
*/
String waitFor() throws InterruptedException, ExecutionException;
default String getIdToken() {
return null;
}
}
public interface Callback {
/**
* Start OAuth callback server at localhost.
*
* @throws IOException if an I/O error occurred.
*/
Session startServer() throws IOException, AuthenticationException;
void grantDeviceCode(String userCode, String verificationURI);
/**
* Open browser
*
* @param url OAuth url.
*/
void openBrowser(String url) throws IOException;
String getClientId();
String getClientSecret();
}
public enum GrantFlow {
AUTHORIZATION_CODE,
DEVICE,
}
public class Result {
private final String accessToken;
private final String refreshToken;
public Result(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
public String getAccessToken() {
return accessToken;
}
public String getRefreshToken() {
return refreshToken;
}
}
private static class DeviceTokenResponse {
@SerializedName("user_code")
public String userCode;
@SerializedName("device_code")
public String deviceCode;
// The URI to be visited for user.
@SerializedName("verification_uri")
public String verificationURI;
// Life time in seconds for device_code and user_code
@SerializedName("expires_in")
public int expiresIn;
// Polling interval
@SerializedName("interval")
public int interval;
}
private static class TokenResponse extends ErrorResponse {
@SerializedName("token_type")
public String tokenType;
@SerializedName("expires_in")
public int expiresIn;
@SerializedName("ext_expires_in")
public int extExpiresIn;
@SerializedName("scope")
public String scope;
@SerializedName("access_token")
public String accessToken;
@SerializedName("refresh_token")
public String refreshToken;
}
private static class ErrorResponse {
@SerializedName("error")
public String error;
@SerializedName("error_description")
public String errorDescription;
@SerializedName("correlation_id")
public String correlationId;
}
/**
* Error response: {"error":"invalid_grant","error_description":"The provided
* value for the 'redirect_uri' is not valid. The value must exactly match the
* redirect URI used to obtain the authorization
* code.","correlation_id":"??????"}
*/
public static class AuthorizationResponse extends ErrorResponse {
@SerializedName("token_type")
public String tokenType;
@SerializedName("expires_in")
public int expiresIn;
@SerializedName("scope")
public String scope;
@SerializedName("access_token")
public String accessToken;
@SerializedName("refresh_token")
public String refreshToken;
@SerializedName("user_id")
public String userId;
@SerializedName("foci")
public String foci;
}
private static class RefreshResponse extends ErrorResponse {
@SerializedName("expires_in")
int expiresIn;
@SerializedName("access_token")
String accessToken;
@SerializedName("refresh_token")
String refreshToken;
}
}

View File

@ -22,6 +22,7 @@ import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.auth.*;
import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.auth.yggdrasil.Texture;
@ -36,11 +37,9 @@ import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.regex.Pattern;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.util.Objects.requireNonNull;
@ -50,20 +49,15 @@ import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
public class MicrosoftService {
private static final String AUTHORIZATION_URL = "https://login.live.com/oauth20_authorize.srf";
private static final String ACCESS_TOKEN_URL = "https://login.live.com/oauth20_token.srf";
private static final String SCOPE = "XboxLive.signin offline_access";
private static final int[] PORTS = {29111, 29112, 29113, 29114, 29115};
private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10,
TimeUnit.SECONDS);
private static final Pattern OAUTH_URL_PATTERN = Pattern
.compile("^https://login\\.live\\.com/oauth20_desktop\\.srf\\?code=(.*?)&lc=(.*?)$");
private final OAuthCallback callback;
private final OAuth.Callback callback;
private final ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> profileRepository;
public MicrosoftService(OAuthCallback callback) {
public MicrosoftService(OAuth.Callback callback) {
this.callback = requireNonNull(callback);
this.profileRepository = new ObservableOptionalCache<>(uuid -> {
LOG.info("Fetching properties of " + uuid);
@ -76,37 +70,11 @@ public class MicrosoftService {
}
public MicrosoftSession authenticate() throws AuthenticationException {
// Example URL:
// https://login.live.com/oauth20_authorize.srf?response_type=code&client_id=6a3728d6-27a3-4180-99bb-479895b8f88e&redirect_uri=http://localhost:29111/auth-response&scope=XboxLive.signin+offline_access&state=612fd24a2447427383e8b222b597db66&prompt=select_account
try {
// Microsoft OAuth Flow
OAuthSession session = callback.startServer();
callback.openBrowser(NetworkUtils.withQuery(AUTHORIZATION_URL,
mapOf(pair("client_id", callback.getClientId()), pair("response_type", "code"),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE),
pair("prompt", "select_account"))));
String code = session.waitFor();
// Authorization Code -> Token
String responseText = HttpRequest.POST(ACCESS_TOKEN_URL)
.form(mapOf(pair("client_id", callback.getClientId()), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", callback.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE)))
.getString();
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText,
LiveAuthorizationResponse.class);
return authenticateViaLiveAccessToken(response.accessToken, response.refreshToken);
OAuth.Result result = OAuth.MICROSOFT.authenticate(OAuth.GrantFlow.DEVICE, new OAuth.Options(SCOPE, callback));
return authenticateViaLiveAccessToken(result.getAccessToken(), result.getRefreshToken());
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (InterruptedException e) {
throw new NoSelectedCharacterException();
} catch (ExecutionException e) {
if (e.getCause() instanceof InterruptedException) {
throw new NoSelectedCharacterException();
} else {
throw new ServerDisconnectException(e);
}
} catch (JsonParseException e) {
throw new ServerResponseMalformedException(e);
}
@ -114,19 +82,8 @@ public class MicrosoftService {
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
try {
LiveRefreshResponse response = HttpRequest.POST(ACCESS_TOKEN_URL)
.form(pair("client_id", callback.getClientId()),
pair("client_secret", callback.getClientSecret()),
pair("refresh_token", oldSession.getRefreshToken()),
pair("grant_type", "refresh_token"))
.accept("application/json")
.ignoreHttpErrorCode(400)
.ignoreHttpErrorCode(401)
.getJson(LiveRefreshResponse.class);
handleLiveErrorMessage(response);
return authenticateViaLiveAccessToken(response.accessToken, response.refreshToken);
OAuth.Result result = OAuth.MICROSOFT.refresh(oldSession.getRefreshToken(), new OAuth.Options(SCOPE, callback));
return authenticateViaLiveAccessToken(result.getAccessToken(), result.getRefreshToken());
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
@ -134,22 +91,6 @@ public class MicrosoftService {
}
}
private void handleLiveErrorMessage(LiveErrorResponse response) throws AuthenticationException {
if (response.error == null || response.errorDescription == null) {
return;
}
switch (response.error) {
case "invalid_grant":
if (response.errorDescription.contains("The user must sign in again and if needed grant the client application access to the requested scope")) {
throw new CredentialExpiredException();
}
break;
}
throw new RemoteAuthenticationException(response.error, response.errorDescription, "");
}
private String getUhs(XBoxLiveAuthenticationResponse response, String existingUhs) throws AuthenticationException {
if (response.errorCode != 0) {
throw new XboxAuthorizationException(response.errorCode, response.redirectUrl);
@ -357,57 +298,6 @@ public class MicrosoftService {
public static class NoXuiException extends AuthenticationException {
}
public static class LiveErrorResponse {
@SerializedName("error")
public String error;
@SerializedName("error_description")
public String errorDescription;
@SerializedName("correlation_id")
public String correlationId;
}
/**
* Error response: {"error":"invalid_grant","error_description":"The provided
* value for the 'redirect_uri' is not valid. The value must exactly match the
* redirect URI used to obtain the authorization
* code.","correlation_id":"??????"}
*/
public static class LiveAuthorizationResponse extends LiveErrorResponse {
@SerializedName("token_type")
public String tokenType;
@SerializedName("expires_in")
public int expiresIn;
@SerializedName("scope")
public String scope;
@SerializedName("access_token")
public String accessToken;
@SerializedName("refresh_token")
public String refreshToken;
@SerializedName("user_id")
public String userId;
@SerializedName("foci")
public String foci;
}
private static class LiveRefreshResponse extends LiveErrorResponse {
@SerializedName("expires_in")
int expiresIn;
@SerializedName("access_token")
String accessToken;
@SerializedName("refresh_token")
String refreshToken;
}
private static class XBoxLiveAuthenticationResponseDisplayClaims {
List<Map<Object, Object>> xui;
}
@ -530,44 +420,6 @@ public class MicrosoftService {
public String developerMessage;
}
public interface OAuthCallback {
/**
* Start OAuth callback server at localhost.
*
* @throws IOException if an I/O error occurred.
*/
OAuthSession startServer() throws IOException, AuthenticationException;
/**
* Open browser
*
* @param url OAuth url.
*/
void openBrowser(String url) throws IOException;
String getClientId();
String getClientSecret();
}
public interface OAuthSession {
String getRedirectURI();
/**
* Wait for authentication
*
* @return authentication code
* @throws InterruptedException if interrupted
* @throws ExecutionException if an I/O error occurred.
*/
String waitFor() throws InterruptedException, ExecutionException;
default String getIdToken() {
return null;
}
}
private static final Gson GSON = new GsonBuilder()
.registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE)
.registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE)

View File

@ -49,6 +49,7 @@ public abstract class HttpRequest {
protected final Map<String, String> headers = new HashMap<>();
protected ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester;
protected final Set<Integer> toleratedHttpCodes = new HashSet<>();
protected boolean ignoreHttpCode;
private HttpRequest(String url, String method) {
this.url = url;
@ -76,6 +77,11 @@ public abstract class HttpRequest {
return this;
}
public HttpRequest ignoreHttpCode() {
ignoreHttpCode = true;
return this;
}
public abstract String getString() throws IOException;
public CompletableFuture<String> getStringAsync() {
@ -173,7 +179,7 @@ public abstract class HttpRequest {
responseCodeTester.accept(new URL(url), con.getResponseCode());
} else {
if (con.getResponseCode() / 100 != 2) {
if (!toleratedHttpCodes.contains(con.getResponseCode())) {
if (!ignoreHttpCode && !toleratedHttpCodes.contains(con.getResponseCode())) {
String data = NetworkUtils.readData(con);
throw new ResponseCodeException(new URL(url), con.getResponseCode(), data);
}