package net.classicube.launcher; import java.net.InetAddress; import java.net.URI; import java.net.URISyntaxException; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.logging.Level; import java.util.regex.Matcher; import java.util.regex.Pattern; import net.classicube.launcher.gui.PromptScreen; // Provides all functionality specific to Minecraft.net: // Signing in, parsing play links, getting server list, getting server details. final class MinecraftNetSession extends GameSession { MinecraftNetSession() { super(GameServiceType.MinecraftNetService); try { siteUri = new URI(HOMEPAGE_URL); } catch (URISyntaxException ex) { // never happens } } // ============================================================================================= // SIGN-IN // ============================================================================================= private static final String PLAY_PAGE_URL = "https://minecraft.net/classic/play", LOGIN_URL = "https://minecraft.net/login", LOGOUT_URL = "https://minecraft.net/logout", CHALLENGE_URL = "https://minecraft.net/challenge", MIGRATED_ACCOUNT_MESSAGE = "Your account has been migrated", WRONG_USER_OR_PASS_MESSAGE = "Oops, unknown username or password.", CHALLENGE_FAILED_MESSAGE = "Could not confirm your identity", CHALLENGE_PASSED_MESSAGE = "Security challenge passed", AUTH_TOKEN_PATTERN = "name=\"authenticityToken\" value=\"([0-9a-f]+)\">", LOGGED_IN_AS_PATTERN = "<param name=\"username\" value=\"(\\S+)\">", COOKIE_NAME = "PLAY_SESSION", CHALLENGE_MESSAGE = "To confirm your identity, please answer the question below", CHALLENGE_QUESTION_PATTERN = "<label for=\"answer\">([^<]+)</label>", CHALLENGE_QUESTION_ID_PATTERN = "name=\"questionId\" value=\"(\\d+)\" />"; private static final Pattern authTokenRegex = Pattern.compile(AUTH_TOKEN_PATTERN), usernameRegex = Pattern.compile(LOGGED_IN_AS_PATTERN), challengeQuestionRegex = Pattern.compile(CHALLENGE_QUESTION_PATTERN), challengeQuestionIdRegex = Pattern.compile(CHALLENGE_QUESTION_ID_PATTERN); @Override public SignInTask signInAsync(final UserAccount account, final boolean remember) { if (account == null) { throw new NullPointerException("account"); } this.account = account; return new SignInWorker(remember); } // Asynchronously try signing in our user private class SignInWorker extends SignInTask { SignInWorker(final boolean remember) { super(remember); } @Override protected SignInResult doInBackground() throws Exception { LogUtil.getLogger().log(Level.FINE, "MinecraftNetSession.SignInWorker"); final boolean restoredSession = loadSessionCookies(this.remember, COOKIE_NAME); boolean relogRequired = false; // download classic singleplayer page to check for logged-in username final String playPage = HttpUtil.downloadString(PLAY_PAGE_URL); if (playPage == null) { return SignInResult.CONNECTION_ERROR; } // See if we're already logged in final Matcher loginMatch = usernameRegex.matcher(playPage); if (loginMatch.find()) { // We ARE signed in! Check the username... final String actualPlayerName = loginMatch.group(1); final boolean nameEquals = actualPlayerName.equalsIgnoreCase(account.playerName); if (remember && nameEquals) { // If we are already signed into the right account, // and we are allowed to reuse sessions... we are done! account.playerName = actualPlayerName; // correct stored-name capitalization (if needed) LogUtil.getLogger().log(Level.INFO, "Restored session for {0}", account.playerName); storeCookies(); return SignInResult.SUCCESS; } else { // Either the restored session was for a different user... if (!nameEquals) { LogUtil.getLogger().log(Level.INFO, "Switching accounts from {0} to {1}", new Object[]{actualPlayerName, account.playerName}); } else { // ...or we are not allowed to restore sessions at all... LogUtil.getLogger().log(Level.INFO, "Unable to reuse old session; signing in anew."); } relogRequired = true; } } else if (restoredSession) { // ...or the saved session did not work (perhaps it expired). LogUtil.getLogger().log(Level.WARNING, "Failed to restore session at Minecraft.net; retrying."); relogRequired = true; } // If needed: log out and clear cookies. if (relogRequired) { HttpUtil.downloadString(LOGOUT_URL); clearStoredSession(); } // download the login page final String loginPage = HttpUtil.downloadString(LOGIN_URL); if (loginPage == null) { return SignInResult.CONNECTION_ERROR; } // Extract authenticityToken from the login page final Matcher loginAuthTokenMatch = authTokenRegex.matcher(loginPage); if (!loginAuthTokenMatch.find()) { // We asked for a login form, but we received something different. Panic! LogUtil.getLogger().log(Level.INFO, loginPage); throw new SignInException("Unrecognized login form served by Minecraft.net"); } // Build up the login request final String authToken = loginAuthTokenMatch.group(1); final StringBuilder requestStr = new StringBuilder(); requestStr.append("username="); requestStr.append(urlEncode(account.signInUsername)); requestStr.append("&password="); requestStr.append(urlEncode(account.password)); requestStr.append("&authenticityToken="); requestStr.append(urlEncode(authToken)); if (remember) { requestStr.append("&remember=true"); } requestStr.append("&redirect="); requestStr.append(urlEncode(HOMEPAGE_URL)); // POST our data to the login handler final String loginResponse = HttpUtil.uploadString(LOGIN_URL, requestStr.toString(), HttpUtil.FORM_DATA); if (loginResponse == null) { return SignInResult.CONNECTION_ERROR; } // Check for common failure scenarios if (loginResponse.contains(WRONG_USER_OR_PASS_MESSAGE)) { return SignInResult.WRONG_USER_OR_PASS; } else if (loginResponse.contains(MIGRATED_ACCOUNT_MESSAGE)) { return SignInResult.MIGRATED_ACCOUNT; } // Check for presence of a challenge question if (loginResponse.contains(CHALLENGE_MESSAGE)) { SignInResult result = handleChallengeQuestions(loginResponse); if (result != SignInResult.SUCCESS) { return result; } } // To confirm that we are signed in, and to find logged-in username, // download classic singleplayer page again. final String playPage2 = HttpUtil.downloadString(PLAY_PAGE_URL); if (playPage2 == null) { return SignInResult.CONNECTION_ERROR; } final Matcher responseMatch = usernameRegex.matcher(playPage2); if (responseMatch.find()) { // ...yes, we are signed in! LogUtil.getLogger().log(Level.INFO, "Successfully signed in as {0} ({1})", new Object[]{account.signInUsername, account.playerName}); // For Mojang accounts, the sign-in name (email) is different from the in-game name (player name). // Minecraft.net pages will display the *player name* after signing in. account.playerName = responseMatch.group(1); // If allowed, remember session (cookies and account info) until next time. if (remember) { storeSession(); } return SignInResult.SUCCESS; } else { // ...no, we did not sign in. And we don't know why. Panic! clearStoredSession(); LogUtil.getLogger().log(Level.INFO, loginResponse); throw new SignInException("Unrecognized response served by minecraft.net"); } } } SignInResult handleChallengeQuestions(final String page) throws SignInException { if (page == null) { throw new NullPointerException("page"); } LogUtil.getLogger().log(Level.FINE, "Minecraft.net asked a challenge question."); // Locate the question text, and other form data, on the page final Matcher challengeMatch = challengeQuestionRegex.matcher(page); final Matcher challengeAuthTokenMatch = authTokenRegex.matcher(page); final Matcher challengeQuestionIdMatch = challengeQuestionIdRegex.matcher(page); if (!challengeMatch.find() || !challengeAuthTokenMatch.find() || !challengeQuestionIdMatch.find()) { LogUtil.getLogger().log(Level.INFO, page); throw new SignInException("Could not parse challenge question."); } final String authToken = challengeAuthTokenMatch.group(1); final String question = challengeMatch.group(1); final int questionId = Integer.parseInt(challengeQuestionIdMatch.group(1)); // Ask user to answer the question String answer = PromptScreen.show("Minecraft.net asks", "<html>Since you are logging in from this computer for the first time,<br>" + "Minecraft.net needs you to confirm your identity before you can continue.<br>" + "This is to make sure that your account isn't used without your authorization." + "<br><br><b>" + question, "", true); if (answer == null) { // If player gave no answer, or closed the window, abort signing in. return SignInResult.CHALLENGE_FAILED; } // POST player's answer, auth token, and question ID to Minecraft.net final StringBuilder challengeRequestStr = new StringBuilder(); challengeRequestStr.append("answer="); challengeRequestStr.append(urlEncode(answer)); challengeRequestStr.append("&authenticityToken="); challengeRequestStr.append(urlEncode(authToken)); challengeRequestStr.append("&questionId="); challengeRequestStr.append(questionId); final String response = HttpUtil.uploadString(CHALLENGE_URL, challengeRequestStr.toString(), HttpUtil.FORM_DATA); // Parse the response if (response == null) { return SignInResult.CONNECTION_ERROR; } else if (response.contains(CHALLENGE_FAILED_MESSAGE)) { // Player answered the question incorrectly. Abort. return SignInResult.CHALLENGE_FAILED; } else if (response.contains(CHALLENGE_PASSED_MESSAGE)) { // Question was answered correctly. Success! return SignInResult.SUCCESS; } else { // We failed, and we don't know why. Panic! LogUtil.getLogger().log(Level.INFO, response); throw new SignInException("Could not pass security question: " + "Unrecognized response served by minecraft.net"); } } // ============================================================================================= // SERVER LIST // ============================================================================================= private static final String SERVER_LIST_URL = "https://minecraft.net/classic/list", SERVER_NAME_PATTERN = "<a href=\"/classic/play/([0-9a-f]+)\">([^<]+)</a>", SERVER_DETAILS_PATTERN = "<td>(\\d+)</td>[^<]+<td>(\\d+)</td>[^<]+<td>([^<]+)</td>[^<]+.+url\\(/images/flags/([a-z]+).png\\)"; private static final Pattern serverNameRegex = Pattern.compile(SERVER_NAME_PATTERN), otherServerDataRegex = Pattern.compile(SERVER_DETAILS_PATTERN); @Override public GetServerListTask getServerListAsync() { return new GetServerListWorker(); } private class GetServerListWorker extends GetServerListTask { @Override protected ServerListEntry[] doInBackground() throws Exception { LogUtil.getLogger().log(Level.FINE, "MinecraftNetGetServerListWorker"); final String serverListString = HttpUtil.downloadString(SERVER_LIST_URL); if (serverListString == null) { throw new RuntimeException("Could not fetch a list of servers from Minecraft.net"); } final Matcher serverListMatch = serverNameRegex.matcher(serverListString); final Matcher otherServerDataMatch = otherServerDataRegex.matcher(serverListString); final ArrayList<ServerListEntry> servers = new ArrayList<>(); // Go through server table, one at a time! while (serverListMatch.find()) { // Fetch server's basic info final ServerListEntry server = new ServerListEntry(); server.hash = serverListMatch.group(1); server.name = htmlDecode(serverListMatch.group(2)); server.name = server.name.replaceAll("…", "..."); final int rowStart = serverListMatch.end(); // Try getting the rest using another regex if (otherServerDataMatch.find(rowStart)) { // this bit doesn't actually work yet (gotta fix my regex) server.players = Integer.parseInt(otherServerDataMatch.group(1)); server.maxPlayers = Integer.parseInt(otherServerDataMatch.group(2)); final String uptimeString = otherServerDataMatch.group(3); try { // "servers.size" is added to preserve ordering for servers // that have otherwise-identical uptime. It makes sure that every // server has higher uptime (by 1 second) than the preceding one. server.uptime = parseUptime(uptimeString) + servers.size(); } catch (final IllegalArgumentException ex) { final String logMsg = String.format( "Error parsing server uptime (\"%s\") for %s", uptimeString, server.name); LogUtil.getLogger().log(Level.WARNING, logMsg, ex); } server.flag = otherServerDataMatch.group(4); } else { LogUtil.getLogger().log(Level.WARNING, "Error passing extended server info for {0}", server.name); } servers.add(server); } // This list is heading off to ServerListScreen (not implemented yet) return servers.toArray(new ServerListEntry[servers.size()]); } } // Parses Minecraft.net server list's way of showing uptime (e.g. 1s, 1m, 1h, 1d) // Returns the number of seconds private static int parseUptime(final String uptime) throws IllegalArgumentException { if (uptime == null) { throw new NullPointerException("uptime"); } final String numPart = uptime.substring(0, uptime.length() - 1); final char unitPart = uptime.charAt(uptime.length() - 1); final int number = Integer.parseInt(numPart); switch (unitPart) { case 's': return number; case 'm': return number * 60; case 'h': return number * 60 * 60; case 'd': return number * 60 * 60 * 24; default: throw new IllegalArgumentException("Invalid date/time parameter."); } } // ============================================================================================= // DETAILS FROM URL // ============================================================================================= private static final String PLAY_HASH_URL_PATTERN = "^https?://" // scheme + "(www\\.)?minecraft.net/classic/play/" // host+path + "([0-9a-fA-F]{28,32})/?" + // hash "(\\?override=(true|1))?$"; // override private static final String IP_PORT_URL_PATTERN = "^https?://" // scheme + "(www\\.)?minecraft.net/classic/play/?" // host+path + "\\?ip=(localhost|(\\d{1,3}\\.){3}\\d{1,3}|([a-zA-Z0-9\\-]+\\.)+([a-zA-Z0-9\\-]+))" // host/IP + "&port=(\\d{1,5})$"; // port private static final Pattern playHashUrlRegex = Pattern.compile(PLAY_HASH_URL_PATTERN), ipPortUrlRegex = Pattern.compile(IP_PORT_URL_PATTERN); @Override public ServerJoinInfo getDetailsFromUrl(final String url) { ServerJoinInfo result = super.getDetailsFromDirectUrl(url); if (result != null) { return result; } final Matcher playHashUrlMatch = playHashUrlRegex.matcher(url); if (playHashUrlMatch.matches()) { result = new ServerJoinInfo(); result.signInNeeded = true; result.passNeeded = true; result.hash = playHashUrlMatch.group(2); if ("1".equals(playHashUrlMatch.group(4)) || "true".equals(playHashUrlMatch.group(4))) { result.override = true; } return result; } final Matcher ipPortUrlMatch = ipPortUrlRegex.matcher(url); if (ipPortUrlMatch.matches()) { result = new ServerJoinInfo(); result.signInNeeded = true; try { result.address = InetAddress.getByName(ipPortUrlMatch.group(2)); } catch (final UnknownHostException ex) { return null; } final String portNum = ipPortUrlMatch.group(6); if (portNum != null && portNum.length() > 0) { try { result.port = Integer.parseInt(portNum); } catch (final NumberFormatException ex) { return null; } } return result; } return null; } // ============================================================================================= // ETC // ============================================================================================= private static final String SKIN_URL = "http://s3.amazonaws.com/MinecraftSkins/", PLAY_URL = "http://minecraft.net/classic/play/", HOMEPAGE_URL = "http://minecraft.net/"; @Override public String getSkinUrl() { return SKIN_URL; } @Override public URI getSiteUri() { return siteUri; } @Override public String getPlayUrl(final String hash) { return PLAY_URL + hash; } @Override public GameServiceType getServiceType() { return GameServiceType.MinecraftNetService; } private URI siteUri; }