fix: Microsoft Account refresh token

This commit is contained in:
huanghongxun 2021-08-22 21:29:08 +08:00
parent 5890f0c782
commit 3b8a0989de
5 changed files with 133 additions and 102 deletions

View File

@ -50,12 +50,6 @@ public class MicrosoftAuthenticationServer extends NanoHTTPD implements Microsof
return String.format("http://localhost:%d/auth-response", port); return String.format("http://localhost:%d/auth-response", port);
} }
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Secret")).orElse(""));
}
@Override @Override
public String waitFor() throws InterruptedException, ExecutionException { public String waitFor() throws InterruptedException, ExecutionException {
return future.get(); return future.get();
@ -113,5 +107,18 @@ public class MicrosoftAuthenticationServer extends NanoHTTPD implements Microsof
// TODO: error! // TODO: error!
FXUtils.openLink(url); FXUtils.openLink(url);
} }
@Override
public String getClientId() {
return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Id")).orElse(""));
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Secret")).orElse(""));
}
} }
} }

View File

@ -24,6 +24,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.util.javafx.BindingMapping; import org.jackhuang.hmcl.util.javafx.BindingMapping;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@ -79,15 +80,15 @@ public class MicrosoftAccount extends Account {
if (service.validate(session.getTokenType(), session.getAccessToken())) { if (service.validate(session.getTokenType(), session.getAccessToken())) {
authenticated = true; authenticated = true;
} else { } else {
MicrosoftSession acquiredSession = service.authenticate(); MicrosoftSession acquiredSession = service.refresh(session);
if (acquiredSession.getProfile() == null) { if (!Objects.equals(acquiredSession.getProfile().getId(), session.getProfile().getId())) {
session = service.refresh(acquiredSession); throw new ServerResponseMalformedException("Selected profile changed");
} else {
session = acquiredSession;
} }
characterUUID = session.getProfile().getId(); session = acquiredSession;
authenticated = true; authenticated = true;
invalidate();
} }
} }

View File

@ -48,7 +48,6 @@ import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.Pair.pair;
public class MicrosoftService { public class MicrosoftService {
private static final String CLIENT_ID = "6a3728d6-27a3-4180-99bb-479895b8f88e";
private static final String AUTHORIZATION_URL = "https://login.live.com/oauth20_authorize.srf"; 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 ACCESS_TOKEN_URL = "https://login.live.com/oauth20_token.srf";
private static final String SCOPE = "XboxLive.signin offline_access"; private static final String SCOPE = "XboxLive.signin offline_access";
@ -81,27 +80,61 @@ public class MicrosoftService {
// Microsoft OAuth Flow // Microsoft OAuth Flow
OAuthSession session = callback.startServer(); OAuthSession session = callback.startServer();
callback.openBrowser(NetworkUtils.withQuery(AUTHORIZATION_URL, callback.openBrowser(NetworkUtils.withQuery(AUTHORIZATION_URL,
mapOf(pair("client_id", CLIENT_ID), pair("response_type", "code"), mapOf(pair("client_id", callback.getClientId()), pair("response_type", "code"),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE), pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE),
pair("prompt", "select_account")))); pair("prompt", "select_account"))));
String code = session.waitFor(); String code = session.waitFor();
// Authorization Code -> Token // Authorization Code -> Token
String responseText = HttpRequest.POST("https://login.live.com/oauth20_token.srf") String responseText = HttpRequest.POST(ACCESS_TOKEN_URL)
.form(mapOf(pair("client_id", CLIENT_ID), pair("code", code), .form(mapOf(pair("client_id", callback.getClientId()), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", session.getClientSecret()), pair("grant_type", "authorization_code"), pair("client_secret", callback.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE))) pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE)))
.getString(); .getString();
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText, LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText,
LiveAuthorizationResponse.class); LiveAuthorizationResponse.class);
return authenticateViaLiveAccessToken(response.accessToken, response.refreshToken);
} 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);
}
}
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").getJson(LiveRefreshResponse.class);
return authenticateViaLiveAccessToken(response.accessToken, response.refreshToken);
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
throw new ServerResponseMalformedException(e);
}
}
private MicrosoftSession authenticateViaLiveAccessToken(String liveAccessToken, String liveRefreshToken) throws IOException, JsonParseException, AuthenticationException {
// Authenticate with XBox Live // Authenticate with XBox Live
XBoxLiveAuthenticationResponse xboxResponse = HttpRequest XBoxLiveAuthenticationResponse xboxResponse = HttpRequest
.POST("https://user.auth.xboxlive.com/user/authenticate") .POST("https://user.auth.xboxlive.com/user/authenticate")
.json(mapOf( .json(mapOf(
pair("Properties", pair("Properties",
mapOf(pair("AuthMethod", "RPS"), pair("SiteName", "user.auth.xboxlive.com"), mapOf(pair("AuthMethod", "RPS"), pair("SiteName", "user.auth.xboxlive.com"),
pair("RpsTicket", "d=" + response.accessToken))), pair("RpsTicket", "d=" + liveAccessToken))),
pair("RelyingParty", "http://auth.xboxlive.com"), pair("TokenType", "JWT"))) pair("RelyingParty", "http://auth.xboxlive.com"), pair("TokenType", "JWT")))
.accept("application/json").getJson(XBoxLiveAuthenticationResponse.class); .accept("application/json").getJson(XBoxLiveAuthenticationResponse.class);
String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs"); String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs");
@ -144,49 +177,12 @@ public class MicrosoftService {
long notAfter = minecraftResponse.expiresIn * 1000L + System.currentTimeMillis(); long notAfter = minecraftResponse.expiresIn * 1000L + System.currentTimeMillis();
// Checking Game Ownership // Get Minecraft Account UUID
MinecraftStoreResponse storeResponse = HttpRequest MinecraftProfileResponse profileResponse = getMinecraftProfile(minecraftResponse.tokenType, minecraftResponse.accessToken);
.GET("https://api.minecraftservices.com/entitlements/mcstore")
.authorization(String.format("%s %s", minecraftResponse.tokenType, minecraftResponse.accessToken))
.getJson(MinecraftStoreResponse.class);
handleErrorResponse(storeResponse);
if (storeResponse.items.isEmpty()) {
throw new NoCharacterException();
}
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, notAfter,
new MicrosoftSession.User(minecraftResponse.username), null);
} 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);
}
}
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
try {
// Get the profile
MinecraftProfileResponse profileResponse = HttpRequest
.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(String.format("%s %s", oldSession.getTokenType(), oldSession.getAccessToken()))
.getJson(MinecraftProfileResponse.class);
handleErrorResponse(profileResponse); handleErrorResponse(profileResponse);
return new MicrosoftSession(oldSession.getTokenType(), oldSession.getAccessToken(),
oldSession.getNotAfter(), oldSession.getUser(), return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, notAfter, liveRefreshToken,
new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name)); new MicrosoftSession.User(minecraftResponse.username), new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name));
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
throw new ServerResponseMalformedException(e);
}
} }
public Optional<MinecraftProfileResponse> getCompleteProfile(String authorization) throws AuthenticationException { public Optional<MinecraftProfileResponse> getCompleteProfile(String authorization) throws AuthenticationException {
@ -202,16 +198,12 @@ public class MicrosoftService {
} }
public boolean validate(String tokenType, String accessToken) throws AuthenticationException { public boolean validate(String tokenType, String accessToken) throws AuthenticationException {
if (true) return false;
requireNonNull(tokenType); requireNonNull(tokenType);
requireNonNull(accessToken); requireNonNull(accessToken);
try { try {
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile")) getMinecraftProfile(tokenType, accessToken);
.authorization(String.format("%s %s", tokenType, accessToken)).filter((url, responseCode) -> {
if (responseCode / 100 == 4) {
throw new ResponseCodeException(url, responseCode);
}
}).getString();
return true; return true;
} catch (ResponseCodeException e) { } catch (ResponseCodeException e) {
return false; return false;
@ -295,6 +287,17 @@ public class MicrosoftService {
String foci; String foci;
} }
private static class LiveRefreshResponse {
@SerializedName("expires_in")
int expiresIn;
@SerializedName("access_token")
String accessToken;
@SerializedName("refresh_token")
String refreshToken;
}
private static class XBoxLiveAuthenticationResponseDisplayClaims { private static class XBoxLiveAuthenticationResponseDisplayClaims {
List<Map<Object, Object>> xui; List<Map<Object, Object>> xui;
} }
@ -421,14 +424,16 @@ public class MicrosoftService {
* @param url OAuth url. * @param url OAuth url.
*/ */
void openBrowser(String url) throws IOException; void openBrowser(String url) throws IOException;
String getClientId();
String getClientSecret();
} }
public interface OAuthSession { public interface OAuthSession {
String getRedirectURI(); String getRedirectURI();
String getClientSecret() throws IOException;
/** /**
* Wait for authentication * Wait for authentication
* *

View File

@ -32,13 +32,15 @@ public class MicrosoftSession {
private final String tokenType; private final String tokenType;
private final long notAfter; private final long notAfter;
private final String accessToken; private final String accessToken;
private final String refreshToken;
private final User user; private final User user;
private final GameProfile profile; private final GameProfile profile;
public MicrosoftSession(String tokenType, String accessToken, long notAfter, User user, GameProfile profile) { public MicrosoftSession(String tokenType, String accessToken, long notAfter, String refreshToken, User user, GameProfile profile) {
this.tokenType = tokenType; this.tokenType = tokenType;
this.accessToken = accessToken; this.accessToken = accessToken;
this.notAfter = notAfter; this.notAfter = notAfter;
this.refreshToken = refreshToken;
this.user = user; this.user = user;
this.profile = profile; this.profile = profile;
} }
@ -55,6 +57,10 @@ public class MicrosoftSession {
return notAfter; return notAfter;
} }
public String getRefreshToken() {
return refreshToken;
}
public String getAuthorization() { public String getAuthorization() {
return String.format("%s %s", getTokenType(), getAccessToken()); return String.format("%s %s", getTokenType(), getAccessToken());
} }
@ -76,18 +82,25 @@ public class MicrosoftSession {
.orElseThrow(() -> new IllegalArgumentException("tokenType is missing")); .orElseThrow(() -> new IllegalArgumentException("tokenType is missing"));
String accessToken = tryCast(storage.get("accessToken"), String.class) String accessToken = tryCast(storage.get("accessToken"), String.class)
.orElseThrow(() -> new IllegalArgumentException("accessToken is missing")); .orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
String refreshToken = tryCast(storage.get("refreshToken"), String.class)
.orElseThrow(() -> new IllegalArgumentException("refreshToken is missing"));
Long notAfter = tryCast(storage.get("notAfter"), Long.class).orElse(0L); Long notAfter = tryCast(storage.get("notAfter"), Long.class).orElse(0L);
String userId = tryCast(storage.get("userid"), String.class) String userId = tryCast(storage.get("userid"), String.class)
.orElseThrow(() -> new IllegalArgumentException("userid is missing")); .orElseThrow(() -> new IllegalArgumentException("userid is missing"));
return new MicrosoftSession(tokenType, accessToken, notAfter, new User(userId), new GameProfile(uuid, name)); return new MicrosoftSession(tokenType, accessToken, notAfter, refreshToken, new User(userId), new GameProfile(uuid, name));
} }
public Map<Object, Object> toStorage() { public Map<Object, Object> toStorage() {
requireNonNull(profile); requireNonNull(profile);
requireNonNull(user); requireNonNull(user);
return mapOf(pair("tokenType", tokenType), pair("accessToken", accessToken), return mapOf(
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())), pair("displayName", profile.getName()), pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())),
pair("displayName", profile.getName()),
pair("tokenType", tokenType),
pair("accessToken", accessToken),
pair("refreshToken", refreshToken),
pair("notAfter", notAfter),
pair("userid", user.id)); pair("userid", user.id));
} }

View File

@ -96,21 +96,26 @@ public abstract class HttpRequest {
} }
} }
public static class HttpPostRequest extends HttpRequest { public static final class HttpPostRequest extends HttpRequest {
private byte[] bytes; private byte[] bytes;
public HttpPostRequest(URL url) { public HttpPostRequest(URL url) {
super(url, "POST"); super(url, "POST");
} }
public <T> HttpPostRequest json(Object payload) throws JsonParseException { public HttpPostRequest json(Object payload) throws JsonParseException {
return string(payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json"); return string(payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
} }
public HttpPostRequest form(Map<String, String> params) { public final HttpPostRequest form(Map<String, String> params) {
return string(NetworkUtils.withQuery("", params), "application/x-www-form-urlencoded"); return string(NetworkUtils.withQuery("", params), "application/x-www-form-urlencoded");
} }
@SafeVarargs
public final HttpPostRequest form(Pair<String, String>... params) {
return form(mapOf(params));
}
public HttpPostRequest string(String payload, String contentType) { public HttpPostRequest string(String payload, String contentType) {
bytes = payload.getBytes(UTF_8); bytes = payload.getBytes(UTF_8);
header("Content-Length", "" + bytes.length); header("Content-Length", "" + bytes.length);