1
0
mirror of https://github.com/SKCraft/Launcher.git synced 2024-11-24 12:16:28 +01:00

Implement Microsoft OAuth login process

This commit is contained in:
Henry Le Grys 2021-02-09 01:24:55 +00:00
parent 3ddaea55dc
commit d2127e9b13
22 changed files with 544 additions and 33 deletions

View File

@ -12,10 +12,7 @@ import com.google.common.base.Strings;
import com.google.common.base.Supplier;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.skcraft.launcher.auth.AccountList;
import com.skcraft.launcher.auth.LoginService;
import com.skcraft.launcher.auth.UserType;
import com.skcraft.launcher.auth.YggdrasilLoginService;
import com.skcraft.launcher.auth.*;
import com.skcraft.launcher.launch.LaunchSupervisor;
import com.skcraft.launcher.model.minecraft.Library;
import com.skcraft.launcher.model.minecraft.VersionManifest;
@ -166,6 +163,10 @@ public final class Launcher {
return new YggdrasilLoginService(HttpRequest.url(getProperties().getProperty("yggdrasilAuthUrl")), accounts.getClientId());
}
public MicrosoftLoginService getMicrosoftLogin() {
return new MicrosoftLoginService(getProperties().getProperty("microsoftClientId"));
}
public LoginService getLoginService(UserType type) {
if (type == UserType.MICROSOFT) {
return null; // TODO: Microsoft login service

View File

@ -0,0 +1,152 @@
package com.skcraft.launcher.auth;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.skcraft.launcher.auth.microsoft.MicrosoftWebAuthorizer;
import com.skcraft.launcher.auth.microsoft.MinecraftServicesAuthorizer;
import com.skcraft.launcher.auth.microsoft.XboxTokenAuthorizer;
import com.skcraft.launcher.auth.microsoft.model.McAuthResponse;
import com.skcraft.launcher.auth.microsoft.model.McProfileResponse;
import com.skcraft.launcher.auth.microsoft.model.TokenResponse;
import com.skcraft.launcher.auth.microsoft.model.XboxAuthorization;
import com.skcraft.launcher.util.HttpRequest;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import java.io.IOException;
import java.net.URL;
import java.util.Collections;
import java.util.Map;
import java.util.function.Consumer;
import static com.skcraft.launcher.util.HttpRequest.url;
@RequiredArgsConstructor
public class MicrosoftLoginService implements LoginService {
private static final URL MS_TOKEN_URL = url("https://login.live.com/oauth20_token.srf");
private final String clientId;
public Session login() throws IOException, InterruptedException, AuthenticationException {
MicrosoftWebAuthorizer authorizer = new MicrosoftWebAuthorizer(clientId);
String code = authorizer.authorize();
TokenResponse response = exchangeToken(form -> {
form.add("grant_type", "authorization_code");
form.add("redirect_uri", authorizer.getRedirectUri());
form.add("code", code);
});
Profile session = performLogin(response.getAccessToken());
session.setRefreshToken(response.getRefreshToken());
return session;
}
@Override
public Session restore(SavedSession savedSession)
throws IOException, InterruptedException, AuthenticationException {
TokenResponse response = exchangeToken(form -> {
form.add("grant_type", "refresh_token");
form.add("refresh_token", savedSession.getRefreshToken());
});
Profile session = performLogin(response.getAccessToken());
session.setRefreshToken(response.getRefreshToken());
return session;
}
private TokenResponse exchangeToken(Consumer<HttpRequest.Form> formConsumer)
throws IOException, InterruptedException, AuthenticationException {
HttpRequest.Form form = HttpRequest.Form.form();
form.add("client_id", clientId);
formConsumer.accept(form);
HttpRequest request = HttpRequest.post(MS_TOKEN_URL)
.bodyForm(form)
.execute();
if (request.getResponseCode() == 200) {
return request.returnContent().asJson(TokenResponse.class);
} else {
TokenError error = request.returnContent().asJson(TokenError.class);
throw new AuthenticationException(error.errorDescription, error.errorDescription);
}
}
private Profile performLogin(String microsoftToken)
throws IOException, InterruptedException, AuthenticationException {
XboxAuthorization xboxAuthorization = XboxTokenAuthorizer.authorizeWithXbox(microsoftToken);
McAuthResponse auth = MinecraftServicesAuthorizer.authorizeWithMinecraft(xboxAuthorization);
McProfileResponse profile = MinecraftServicesAuthorizer.getUserProfile(auth);
Profile session = new Profile(auth, profile);
session.setAvatarImage(VisageSkinService.fetchSkinHead(profile.getUuid()));
return session;
}
@Data
public static class Profile implements Session {
private final McAuthResponse auth;
private final McProfileResponse profile;
private final Map<String, String> userProperties = Collections.emptyMap();
private String refreshToken;
private String avatarImage;
@Override
public String getUuid() {
return profile.getUuid();
}
@Override
public String getName() {
return profile.getName();
}
@Override
public String getAccessToken() {
return auth.getAccessToken();
}
@Override
public String getSessionToken() {
return String.format("token:%s:%s", getAccessToken(), getUuid());
}
@Override
public UserType getUserType() {
return UserType.MICROSOFT;
}
@Override
public boolean isOnline() {
return true;
}
@Override
public SavedSession toSavedSession() {
SavedSession savedSession = new SavedSession();
savedSession.setType(getUserType());
savedSession.setUsername(getName());
savedSession.setUuid(getUuid());
savedSession.setAccessToken(getAccessToken());
savedSession.setRefreshToken(getRefreshToken());
savedSession.setAvatarImage(getAvatarImage());
return savedSession;
}
}
@Data
@JsonNaming(PropertyNamingStrategy.LowerCaseWithUnderscoresStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
private static class TokenError {
private String error;
private String errorDescription;
}
}

View File

@ -37,11 +37,6 @@ public class OfflineSession implements Session {
return (new UUID(0, 0)).toString();
}
@Override
public String getClientToken() {
return "0";
}
@Override
public String getAccessToken() {
return "0";

View File

@ -27,13 +27,6 @@ public interface Session {
*/
String getName();
/**
* Get the client token.
*
* @return client token
*/
String getClientToken();
/**
* Get the access token.
*

View File

@ -124,12 +124,6 @@ public class YggdrasilLoginService implements LoginService {
return String.format("token:%s:%s", getAccessToken(), getUuid());
}
@Override
@JsonIgnore
public String getClientToken() {
return response.getClientToken();
}
@Override
@JsonIgnore
public String getAccessToken() {

View File

@ -0,0 +1,57 @@
package com.skcraft.launcher.auth.microsoft;
import com.skcraft.launcher.auth.AuthenticationException;
import com.skcraft.launcher.util.HttpRequest;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.awt.*;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
/**
* Handles the Microsoft leg of OAuth authorization.
*/
@RequiredArgsConstructor
public class MicrosoftWebAuthorizer {
private final String clientId;
@Getter private String redirectUri;
public String authorize() throws IOException, AuthenticationException, InterruptedException {
if (Desktop.isDesktopSupported()) {
// Interactive auth
return authorizeInteractive();
} else {
// TODO Device code auth
return null;
}
}
private String authorizeInteractive() throws IOException, AuthenticationException, InterruptedException {
OauthHttpHandler httpHandler = new OauthHttpHandler();
Desktop.getDesktop().browse(generateInteractiveUrl(httpHandler.getPort()));
return httpHandler.await();
}
private URI generateInteractiveUrl(int port) throws AuthenticationException {
redirectUri = "http://localhost:" + port;
URI interactive;
try {
HttpRequest.Form query = HttpRequest.Form.form();
query.add("client_id", clientId);
query.add("scope", "XboxLive.signin XboxLive.offline_access");
query.add("response_type", "code");
query.add("redirect_uri", redirectUri);
interactive = new URI("https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?"
+ query.toString());
} catch (URISyntaxException e) {
throw new AuthenticationException(e, "Failed to generate OAuth URL");
}
return interactive;
}
}

View File

@ -0,0 +1,42 @@
package com.skcraft.launcher.auth.microsoft;
import com.skcraft.launcher.auth.AuthenticationException;
import com.skcraft.launcher.auth.microsoft.model.*;
import com.skcraft.launcher.util.HttpRequest;
import java.io.IOException;
import java.net.URL;
import static com.skcraft.launcher.util.HttpRequest.url;
public class MinecraftServicesAuthorizer {
private static final URL MC_SERVICES_LOGIN = url("https://api.minecraftservices.com/authentication/login_with_xbox");
private static final URL MC_SERVICES_PROFILE = url("https://api.minecraftservices.com/minecraft/profile");
public static McAuthResponse authorizeWithMinecraft(XboxAuthorization auth) throws IOException, InterruptedException {
McAuthRequest request = new McAuthRequest("XBL3.0 x=" + auth.getCombinedToken());
return HttpRequest.post(MC_SERVICES_LOGIN)
.bodyJson(request)
.header("Accept", "application/json")
.execute()
.expectResponseCode(200)
.returnContent()
.asJson(McAuthResponse.class);
}
public static McProfileResponse getUserProfile(McAuthResponse auth)
throws IOException, InterruptedException, AuthenticationException {
HttpRequest request = HttpRequest.get(MC_SERVICES_PROFILE)
.header("Authorization", auth.getAuthorization())
.execute();
if (request.getResponseCode() == 200) {
return request.returnContent().asJson(McProfileResponse.class);
} else {
McServicesError error = request.returnContent().asJson(McServicesError.class);
throw new AuthenticationException(error.getErrorMessage(), error.getErrorMessage());
}
}
}

View File

@ -0,0 +1,62 @@
package com.skcraft.launcher.auth.microsoft;
import com.google.common.base.Charsets;
import com.google.common.base.Splitter;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import lombok.extern.java.Log;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
@Log
public class OauthHttpHandler {
private Executor executor = Executors.newCachedThreadPool();
private HttpServer server;
private String result;
public OauthHttpHandler() throws IOException {
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/", new Handler());
server.setExecutor(executor);
server.start();
}
public int getPort() {
return server.getAddress().getPort();
}
public String await() throws InterruptedException {
synchronized (this) {
this.wait();
}
server.stop(3);
return result;
}
private class Handler implements HttpHandler {
@Override
public void handle(HttpExchange httpExchange) throws IOException {
String query = httpExchange.getRequestURI().getQuery();
Map<String, String> qs = Splitter.on('&').withKeyValueSeparator('=').split(query);
result = qs.get("code");
synchronized (OauthHttpHandler.this) {
OauthHttpHandler.this.notifyAll();
}
byte[] response = "OK: you can close the browser now".getBytes(Charsets.UTF_8);
httpExchange.sendResponseHeaders(200, response.length);
httpExchange.getResponseBody().write(response);
httpExchange.getResponseBody().flush();
httpExchange.getResponseBody().close();
}
}
}

View File

@ -0,0 +1,41 @@
package com.skcraft.launcher.auth.microsoft;
import com.skcraft.launcher.auth.microsoft.model.*;
import com.skcraft.launcher.util.HttpRequest;
import java.io.IOException;
import java.net.URL;
import static com.skcraft.launcher.util.HttpRequest.url;
public class XboxTokenAuthorizer {
private static final URL XBL_AUTHENTICATE_URL = url("https://user.auth.xboxlive.com/user/authenticate");
private static final URL XSTS_AUTHENTICATE_URL = url("https://xsts.auth.xboxlive.com/xsts/authorize");
public static XboxAuthorization authorizeWithXbox(String accessToken) throws IOException, InterruptedException {
XboxAuthRequest<XblAuthProperties> xblPayload =
new XboxAuthRequest<>(new XblAuthProperties("d=" + accessToken));
XboxAuthResponse xblResponse = HttpRequest.post(XBL_AUTHENTICATE_URL)
.bodyJson(xblPayload)
.header("Accept", "application/json")
.execute()
.expectResponseCode(200)
.returnContent()
.asJson(XboxAuthResponse.class);
XboxAuthRequest<XstsAuthProperties> xstsPayload =
new XboxAuthRequest<>(new XstsAuthProperties(xblResponse.getToken()));
xstsPayload.setRelyingParty("rp://api.minecraftservices.com/");
XboxAuthResponse xstsResponse = HttpRequest.post(XSTS_AUTHENTICATE_URL)
.bodyJson(xstsPayload)
.header("Accept", "application/json")
.execute()
.expectResponseCode(200)
.returnContent()
.asJson(XboxAuthResponse.class);
return new XboxAuthorization(xstsResponse.getToken(), xstsResponse.getUhs());
}
}

View File

@ -0,0 +1,8 @@
package com.skcraft.launcher.auth.microsoft.model;
import lombok.Data;
@Data
public class McAuthRequest {
private final String identityToken;
}

View File

@ -0,0 +1,21 @@
package com.skcraft.launcher.auth.microsoft.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
@Data
@JsonNaming(PropertyNamingStrategy.LowerCaseWithUnderscoresStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public class McAuthResponse {
private String accessToken;
private String tokenType;
private int expiresIn;
@JsonIgnore
public String getAuthorization() {
return String.format("%s %s", tokenType, accessToken);
}
}

View File

@ -0,0 +1,12 @@
package com.skcraft.launcher.auth.microsoft.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class McProfileResponse {
@JsonProperty("id") private String uuid;
private String name;
}

View File

@ -0,0 +1,11 @@
package com.skcraft.launcher.auth.microsoft.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class McServicesError {
private String error;
private String errorMessage;
}

View File

@ -0,0 +1,15 @@
package com.skcraft.launcher.auth.microsoft.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
@Data
@JsonNaming(PropertyNamingStrategy.LowerCaseWithUnderscoresStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public class TokenResponse {
private String tokenType;
private String accessToken;
private String refreshToken;
}

View File

@ -0,0 +1,15 @@
package com.skcraft.launcher.auth.microsoft.model;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
import lombok.NonNull;
@Data
@JsonNaming(PropertyNamingStrategy.PascalCaseStrategy.class)
public class XblAuthProperties {
private String authMethod = "RPS";
private String siteName = "user.auth.xboxlive.com";
@NonNull
private String rpsTicket;
}

View File

@ -0,0 +1,14 @@
package com.skcraft.launcher.auth.microsoft.model;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
import lombok.NonNull;
@Data
@JsonNaming(PropertyNamingStrategy.PascalCaseStrategy.class)
public class XboxAuthRequest<T> {
@NonNull private T properties;
private String relyingParty = "http://auth.xboxlive.com";
private String tokenType = "JWT";
}

View File

@ -0,0 +1,32 @@
package com.skcraft.launcher.auth.microsoft.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
import java.util.List;
@Data
@JsonNaming(PropertyNamingStrategy.PascalCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public class XboxAuthResponse {
private String token;
private DisplayClaims displayClaims;
@JsonIgnore
public String getUhs() {
return getDisplayClaims().getXui().get(0).getUhs();
}
@Data
public static class DisplayClaims {
private List<UhsContainer> xui;
}
@Data
public static class UhsContainer {
private String uhs;
}
}

View File

@ -0,0 +1,13 @@
package com.skcraft.launcher.auth.microsoft.model;
import lombok.Data;
@Data
public class XboxAuthorization {
private final String token;
private final String uhs;
public String getCombinedToken() {
return String.format("%s;%s", uhs, token);
}
}

View File

@ -0,0 +1,19 @@
package com.skcraft.launcher.auth.microsoft.model;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
import java.util.Collections;
import java.util.List;
@Data
@JsonNaming(PropertyNamingStrategy.PascalCaseStrategy.class)
public class XstsAuthProperties {
private String sandboxId = "RETAIL";
private List<String> userTokens;
public XstsAuthProperties(String token) {
this.userTokens = Collections.singletonList(token);
}
}

View File

@ -2,6 +2,8 @@ package com.skcraft.launcher.dialog;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.skcraft.concurrency.DefaultProgress;
import com.skcraft.concurrency.ObservableFuture;
import com.skcraft.concurrency.ProgressObservable;
import com.skcraft.launcher.Launcher;
@ -84,7 +86,7 @@ public class AccountSelectDialog extends JDialog {
add(listPane, BorderLayout.CENTER);
add(buttonsPanel, BorderLayout.SOUTH);
loginButton.addActionListener(ev -> attemptLogin(accountList.getSelectedValue()));
loginButton.addActionListener(ev -> attemptExistingLogin(accountList.getSelectedValue()));
cancelButton.addActionListener(ev -> dispose());
addMojangButton.addActionListener(ev -> {
@ -96,9 +98,7 @@ public class AccountSelectDialog extends JDialog {
}
});
addMicrosoftButton.addActionListener(ev -> {
// TODO
});
addMicrosoftButton.addActionListener(ev -> attemptMicrosoftLogin());
removeSelected.addActionListener(ev -> {
if (accountList.getSelectedValue() != null) {
@ -133,7 +133,25 @@ public class AccountSelectDialog extends JDialog {
dispose();
}
private void attemptLogin(SavedSession session) {
private void attemptMicrosoftLogin() {
ListenableFuture<?> future = launcher.getExecutor().submit(() -> {
Session newSession = launcher.getMicrosoftLogin().login();
if (newSession != null) {
launcher.getAccounts().add(newSession.toSavedSession());
setResult(newSession);
}
return null;
});
String status = SharedLocale.tr("login.loggingInStatus");
ProgressDialog.showProgress(this, future, new DefaultProgress(-1, status),
SharedLocale.tr("login.loggingInTitle"), status);
SwingHelper.addErrorDialogCallback(this, future);
}
private void attemptExistingLogin(SavedSession session) {
if (session == null) return;
LoginService loginService = launcher.getLoginService(session.getType());

View File

@ -99,15 +99,10 @@ login.playOffline=Play offline
login.title=Minecraft Login
login.idEmail=ID/Email\:
login.password=Password\:
login.forgetUser=Forget selected user
login.forgetPassword=Forget password
login.forgetAllPasswords=Forget all passwords...
login.confirmForgetAllPasswords=Are you sure that you want to forget all saved passwords?
login.forgetAllPasswordsTitle=Forget passwords
login.noPasswordError=Please enter a password.
login.noPasswordTitle=Missing Password
login.loggingInTitle=Logging in...
login.loggingInStatus=Logging in to Mojang...
login.loggingInStatus=Logging in to Minecraft...
login.noLoginError=Please enter your account details.
login.noLoginTitle=Missing Account
login.minecraftNotOwnedError=Sorry, Minecraft is not owned on that account.

View File

@ -13,6 +13,7 @@ versionManifestUrl=https://launchermeta.mojang.com/mc/game/version_manifest.json
librariesSource=https://libraries.minecraft.net/
assetsSource=http://resources.download.minecraft.net/
yggdrasilAuthUrl=https://authserver.mojang.com/authenticate
microsoftClientId=d18bb4d8-a27f-4451-a87f-fe6de4436813
resetPasswordUrl=https://minecraft.net/resetpassword
newsUrl=http://update.skcraft.com/template/news.html?version=%s