From d8a4d0903acdf03721e200eb2d570fc247af5012 Mon Sep 17 00:00:00 2001 From: norhu1130 Date: Wed, 13 Jan 2021 10:23:40 +0900 Subject: [PATCH 1/4] MSA --- app/assets/css/launcher.css | 10 ++ app/assets/js/authmanager.js | 94 +++++++++++++--- app/assets/js/configmanager.js | 28 ++++- app/assets/js/microsoft.js | 191 +++++++++++++++++++++++++++++++++ app/assets/js/scripts/login.js | 69 ++++++++++++ app/login.ejs | 1 + index.js | 47 ++++++++ 7 files changed, 420 insertions(+), 20 deletions(-) create mode 100644 app/assets/js/microsoft.js diff --git a/app/assets/css/launcher.css b/app/assets/css/launcher.css index 56816997fb..48a237bfb4 100644 --- a/app/assets/css/launcher.css +++ b/app/assets/css/launcher.css @@ -858,6 +858,16 @@ body, button { border-width: 0 2px 2px 0; transform: rotate(45deg); } +#loginMSButton { + border-color: transparent; + background-color: transparent; + cursor: pointer; + font-family: 'NanumGothicBold'; + font-size: 12px; + font-weight: bold; + margin-bottom: 3px; + color: rgba(255, 255, 255, 0.75); +} /* #login_filter { diff --git a/app/assets/js/authmanager.js b/app/assets/js/authmanager.js index 22b2fed97f..3a094a3999 100644 --- a/app/assets/js/authmanager.js +++ b/app/assets/js/authmanager.js @@ -12,9 +12,64 @@ const ConfigManager = require('./configmanager') const LoggerUtil = require('./loggerutil') const Mojang = require('./mojang') +const Microsoft = require('./microsoft') const logger = LoggerUtil('%c[AuthManager]', 'color: #a02d2a; font-weight: bold') const loggerSuccess = LoggerUtil('%c[AuthManager]', 'color: #209b07; font-weight: bold') +async function validateSelectedMojang() { + const current = ConfigManager.getSelectedAccount() + const isValid = await Mojang.validate(current.accessToken, ConfigManager.getClientToken()) + if(!isValid){ + try { + const session = await Mojang.refresh(current.accessToken, ConfigManager.getClientToken()) + ConfigManager.updateAuthAccount(current.uuid, session.accessToken) + ConfigManager.save() + } catch(err) { + logger.debug('Error while validating selected profile:', err) + if(err && err.error === 'ForbiddenOperationException'){ + // What do we do? + } + logger.log('Account access token is invalid.') + return false + } + loggerSuccess.log('Account access token validated.') + return true + } else { + loggerSuccess.log('Account access token validated.') + return true + } +} + +async function validateSelectedMicrosoft() { + try { + const current = ConfigManager.getSelectedAccount() + const now = new Date().getTime() + const MCExpiresAt = Date.parse(current.expiresAt) + const MCExpired = now > MCExpiresAt + + if(MCExpired) { + const MSExpiresAt = Date.parse(ConfigManager.getMicrosoftAuth().expires_at) + const MSExpired = now > MSExpiresAt + + if (MSExpired) { + const newAccessToken = await Microsoft.refreshAccessToken(ConfigManager.getMicrosoftAuth) + ConfigManager.updateMicrosoftAuth(newAccessToken.access_token, newAccessToken.expires_at) + ConfigManager.save() + } + const newMCAccessToken = await Microsoft.authMinecraft(ConfigManager.getMicrosoftAuth().access_token) + ConfigManager.updateAuthAccount(current.uuid, newMCAccessToken.access_token, newMCAccessToken.expires_at) + ConfigManager.save() + + return true + } else { + return true + } + } catch (error) { + return Promise.reject(error) + } +} + +// Exports // Functions /** @@ -78,22 +133,31 @@ exports.validateSelected = async function(){ const current = ConfigManager.getSelectedAccount() const isValid = await Mojang.validate(current.accessToken, ConfigManager.getClientToken()) if(!isValid){ - try { - const session = await Mojang.refresh(current.accessToken, ConfigManager.getClientToken()) - ConfigManager.updateAuthAccount(current.uuid, session.accessToken) - ConfigManager.save() - } catch(err) { - logger.debug('Error while validating selected profile:', err) - if(err && err.error === 'ForbiddenOperationException'){ - // What do we do? + try{ + if (ConfigManager.getSelectedAccount() === 'microsoft') { + const validate = await validateSelectedMicrosoft() + return validate + } else { + const validate = await validateSelectedMojang() + return validate } - logger.log('Account access token is invalid.') - return false + } catch (error) { + return Promise.reject(error) } - loggerSuccess.log('Account access token validated.') - return true - } else { - loggerSuccess.log('Account access token validated.') - return true + } +} + +exports.addMSAccount = async authCode => { + try { + const accessToken = await Microsoft.getAccessToken(authCode) + ConfigManager.setMicrosoftAuth(accessToken) + const MCAccessToken = await Microsoft.authMinecraft(accessToken.access_token) + const MCProfile = await Microsoft.getMCProfile(MCAccessToken.access_token) + const ret = ConfigManager.addAuthAccount(MCProfile.id, MCAccessToken.access_token, MCProfile.name, MCProfile.name, MCAccessToken.expires_at, 'microsoft') + ConfigManager.save() + + return ret + } catch(error) { + return Promise.reject(error) } } \ No newline at end of file diff --git a/app/assets/js/configmanager.js b/app/assets/js/configmanager.js index 65a7306173..2af0022e2b 100644 --- a/app/assets/js/configmanager.js +++ b/app/assets/js/configmanager.js @@ -103,7 +103,8 @@ const DEFAULT_CONFIG = { selectedServer: null, // Resolved selectedAccount: null, authenticationDatabase: {}, - modConfigurations: [] + modConfigurations: [], + microsoftAuth: {} } let config = null @@ -327,6 +328,7 @@ exports.getAuthAccount = function(uuid){ */ exports.updateAuthAccount = function(uuid, accessToken){ config.authenticationDatabase[uuid].accessToken = accessToken + config.authenticationDatabase[uuid].expiresAt = expiresAt return config.authenticationDatabase[uuid] } @@ -340,17 +342,18 @@ exports.updateAuthAccount = function(uuid, accessToken){ * * @returns {Object} The authenticated account object created by this action. */ -exports.addAuthAccount = function(uuid, accessToken, username, displayName){ +exports.addAuthAccount = function(uuid, accessToken, username, displayName, expiresAt = null, type = 'mojang'){ config.selectedAccount = uuid config.authenticationDatabase[uuid] = { accessToken, username: username.trim(), uuid: uuid.trim(), - displayName: displayName.trim() + displayName: displayName.trim(), + expiresAt: expiresAt, + type: type } return config.authenticationDatabase[uuid] } - /** * Remove an authenticated account from the database. If the account * was also the selected account, a new one will be selected. If there @@ -685,4 +688,19 @@ exports.getAllowPrerelease = function(def = false){ */ exports.setAllowPrerelease = function(allowPrerelease){ config.settings.launcher.allowPrerelease = allowPrerelease -} \ No newline at end of file +} + +exports.setMicrosoftAuth = microsoftAuth => { + config.microsoftAuth = microsoftAuth +} + +exports.getMicrosoftAuth = () => { + return config.microsoftAuth +} + +exports.updateMicrosoftAuth = (accessToken, expiresAt) => { + config.microsoftAuth.access_token = accessToken + config.microsoftAuth.expires_at = expiresAt + + return config.microsoftAuth +} \ No newline at end of file diff --git a/app/assets/js/microsoft.js b/app/assets/js/microsoft.js new file mode 100644 index 0000000000..cca5b8a953 --- /dev/null +++ b/app/assets/js/microsoft.js @@ -0,0 +1,191 @@ +// Requirements +const request = require('request') +// const logger = require('./loggerutil')('%c[Microsoft]', 'color: #01a6f0; font-weight: bold') + +// Constants +const clientId = 'Client ID(Azure)' +const tokenUri = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token' +const authXBLUri = 'https://user.auth.xboxlive.com/user/authenticate' +const authXSTSUri = 'https://xsts.auth.xboxlive.com/xsts/authorize' +const authMCUri = 'https://api.minecraftservices.com/authentication/login_with_xbox' +const profileURI ='https://api.minecraftservices.com/minecraft/profile' + +// Functions +function requestPromise(uri, options) { + return new Promise((resolve, reject) => { + request(uri, options, (error, response, body) => { + if (error) { + reject(error) + } else if (response.statusCode !== 200){ + reject([response.statusCode, response.statusMessage, response]) + } else { + resolve(response) + } + }) + }) +} + +function getXBLToken(accessToken) { + return new Promise((resolve, reject) => { + const data = new Object() + + const options = { + method: 'post', + json: { + Properties: { + AuthMethod: 'RPS', + SiteName: 'user.auth.xboxlive.com', + RpsTicket: `d=${accessToken}` + }, + RelyingParty: 'http://auth.xboxlive.com', + TokenType: 'JWT' + } + } + requestPromise(authXBLUri, options).then(response => { + const body = response.body + + data.token = body.Token + data.uhs = body.DisplayClaims.xui[0].uhs + + resolve(data) + }).catch(error => { + reject(error) + }) + }) +} + +function getXSTSToken(XBLToken) { + return new Promise((resolve, reject) => { + const options = { + method: 'post', + json: { + Properties: { + SandboxId: 'RETAIL', + UserTokens: [XBLToken] + }, + RelyingParty: 'rp://api.minecraftservices.com/', + TokenType: 'JWT' + } + } + requestPromise(authXSTSUri, options).then(response => { + const body = response.body + + resolve(body.Token) + }).catch(error => { + reject(error) + }) + }) +} + +function getMCAccessToken(UHS, XSTSToken) { + return new Promise((resolve, reject) => { + const data = new Object() + const expiresAt = new Date() + + const options = { + method: 'post', + json: { + identityToken: `XBL3.0 x=${UHS};${XSTSToken}` + } + } + requestPromise(authMCUri, options).then(response => { + const body = response.body + + expiresAt.setSeconds(expiresAt.getSeconds() + body.expires_in) + data.access_token = body.access_token + data.expires_at = expiresAt + + resolve(data) + }).catch(error => { + reject(error) + }) + }) +} + +// Exports +exports.getAccessToken = authCode => { + return new Promise((resolve, reject) => { + const expiresAt = new Date() + const data = new Object() + + const options = { + method: 'post', + formData: { + client_id: clientId, + code: authCode, + scope: 'XboxLive.signin', + redirect_uri: 'https://login.microsoftonline.com/common/oauth2/nativeclient', + grant_type: 'authorization_code' + } + } + requestPromise(tokenUri, options).then(response => { + const body = JSON.parse(response.body) + expiresAt.setSeconds(expiresAt.getSeconds() + body.expires_in) + data.expires_at = expiresAt + data.access_token = body.access_token + data.refresh_token = body.refresh_token + + resolve(data) + }).catch(error => { + reject(error) + }) + }) +} + +exports.refreshAccessToken = refreshToken => { + return new Promise((resolve, reject) => { + const expiresAt = new Date() + const data = new Object() + + const options = { + method: 'post', + formData: { + client_id: clientId, + refresh_token: refreshToken, + scope: 'XboxLive.signin', + redirect_uri: 'https://login.microsoftonline.com/common/oauth2/nativeclient', + grant_type: 'refresh_token' + } + } + requestPromise(tokenUri, options).then(response => { + const body = JSON.parse(response.body) + expiresAt.setSeconds(expiresAt.getSeconds() + body.expires_in) + data.expires_at = expiresAt + data.access_token = body.access_token + + resolve(data) + }).catch(error => { + reject(error) + }) + }) +} + +exports.authMinecraft = async accessToken => { + try { + const XBLToken = await getXBLToken(accessToken) + const XSTSToken = await getXSTSToken(XBLToken.token) + const MCToken = await getMCAccessToken(XBLToken.uhs, XSTSToken) + + return MCToken + } catch (error) { + Promise.reject(error) + } +} + +exports.getMCProfile = MCAccessToken => { + return new Promise((resolve, reject) => { + const options = { + method: 'get', + headers: { + Authorization: `Bearer ${MCAccessToken}` + } + } + requestPromise(profileURI, options).then(response => { + const body = JSON.parse(response.body) + + resolve(body) + }).catch(error => { + reject(error) + }) + }) +} \ No newline at end of file diff --git a/app/assets/js/scripts/login.js b/app/assets/js/scripts/login.js index 34078bd1dc..cddd9491c1 100644 --- a/app/assets/js/scripts/login.js +++ b/app/assets/js/scripts/login.js @@ -17,6 +17,7 @@ const checkmarkContainer = document.getElementById('checkmarkContainer') const loginRememberOption = document.getElementById('loginRememberOption') const loginButton = document.getElementById('loginButton') const loginForm = document.getElementById('loginForm') +const loginMSButton = document.getElementById('loginMSButton') // Control variables. let lu = false, lp = false @@ -297,4 +298,72 @@ loginButton.addEventListener('click', () => { loggerLogin.log('Error while logging in.', err) }) +}) + +loginMSButton.addEventListener('click', (event) => { + ipcRenderer.send('openMSALoginWindow', 'open') +}) + +ipcRenderer.on('MSALoginWindowReply', (event, ...args) => { + if (args[0] === 'error') { + setOverlayContent('LOGIN FAIL', 'Theres a window already open!', 'OK') + setOverlayHandler(() => { + toggleOverlay(false) + }) + toggleOverlay(true) + return + } + + const queryMap = args[0] + if(queryMap.has('error')) { + let error = queryMap.get('error') + let errorDesc = queryMap.get('error_description') + setOverlayContent(error, errorDesc, 'OK') + setOverlayHandler(() => { + toggleOverlay(false) + }) + toggleOverlay(true) + return + } + + // Disable form. + formDisabled(true) + + // Show loading stuff. + loginLoading(true) + + const authCode = queryMap.get('code') + AuthManager.addMSAccount(authCode).then(account => { + updateSelectedAccount(account) + loginButton.innerHTML = loginButton.innerHTML.replace(Lang.queryJS('login.loggingIn'), Lang.queryJS('login.success')) + $('.circle-loader').toggleClass('load-complete') + $('.checkmark').toggle() + setTimeout(() => { + switchView(VIEWS.login, loginViewOnSuccess, 500, 500, () => { + // Temporary workaround + if(loginViewOnSuccess === VIEWS.settings){ + prepareSettings() + } + loginViewOnSuccess = VIEWS.landing // Reset this for good measure. + loginCancelEnabled(false) // Reset this for good measure. + loginViewCancelHandler = null // Reset this for good measure. + loginUsername.value = '' + loginPassword.value = '' + $('.circle-loader').toggleClass('load-complete') + $('.checkmark').toggle() + loginLoading(false) + loginButton.innerHTML = loginButton.innerHTML.replace(Lang.queryJS('login.success'), Lang.queryJS('login.login')) + formDisabled(false) + }) + }, 1000) + }).catch(error => { + loginLoading(false) + setOverlayContent('ERROR!', 'Report Plz!', Lang.queryJS('login.tryAgain')) + setOverlayHandler(() => { + formDisabled(false) + toggleOverlay(false) + }) + toggleOverlay(true) + loggerLogin.error(error) + }) }) \ No newline at end of file diff --git a/app/login.ejs b/app/login.ejs index 18d93a00b5..8ad0e51855 100644 --- a/app/login.ejs +++ b/app/login.ejs @@ -9,6 +9,7 @@
MINECRAFT LOGIN +
diff --git a/index.js b/index.js index 9f2ef6c59d..f35657ba2f 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,8 @@ const path = require('path') const semver = require('semver') const url = require('url') +const redirectUriPrefix = 'https://login.microsoftonline.com/common/oauth2/nativeclient?' + // Setup auto updater. function initAutoUpdater(event, data) { @@ -85,6 +87,49 @@ ipcMain.on('distributionIndexDone', (event, res) => { // https://electronjs.org/docs/tutorial/offscreen-rendering app.disableHardwareAcceleration() +let MSALoginWindow = null + +// Open the Microsoft Account Login window +ipcMain.on('openMSALoginWindow', (ipcEvent, args) => { + if(MSALoginWindow != null){ + ipcEvent.sender.send('MSALoginWindowNotification', 'error', 'AlreadyOpenException') + return + } + MSALoginWindow = new BrowserWindow({ + minWidth: 600, + minHeight: 400, + width: 600, + height: 400, + contextIsolation: false + }) + + MSALoginWindow.on('closed', () => { + MSALoginWindow = null + }) + + MSALoginWindow.webContents.on('did-navigate', (event, uri, responseCode, statusText) => { + if(uri.startsWith(redirectUriPrefix)) { + let querys = uri.substring(redirectUriPrefix.length).split('#', 1).toString().split('&') + let queryMap = new Map() + + querys.forEach(query => { + let arr = query.split('=') + queryMap.set(arr[0], decodeURI(arr[1])) + }) + + ipcEvent.reply('MSALoginWindowReply', queryMap) + + MSALoginWindow.close() + MSALoginWindow = null + } + }) + + MSALoginWindow.removeMenu() + MSALoginWindow.loadURL('https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=a621aefe-b326-4c67-8688-34746ccd9bd2&response_type=code&scope=XboxLive.signin%20offline_access&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient') +}) + + + // https://github.com/electron/electron/issues/18397 app.allowRendererProcessReuse = true @@ -147,6 +192,8 @@ function createMenu() { accelerator: 'Command+Q', click: () => { app.quit() + if(MSALoginWindow !== null) MSALoginWindow.close() + MSALoginWindow = null } }] } From 7dc2df9a78e105158aa0a8c1c9b3943c71503425 Mon Sep 17 00:00:00 2001 From: Star <46096192+norhu1130@users.noreply.github.com> Date: Wed, 13 Jan 2021 10:25:00 +0900 Subject: [PATCH 2/4] Font Fix --- app/assets/css/launcher.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/css/launcher.css b/app/assets/css/launcher.css index 48a237bfb4..f555474abd 100644 --- a/app/assets/css/launcher.css +++ b/app/assets/css/launcher.css @@ -862,7 +862,7 @@ body, button { border-color: transparent; background-color: transparent; cursor: pointer; - font-family: 'NanumGothicBold'; + font-family: 'Avenir Medium'; font-size: 12px; font-weight: bold; margin-bottom: 3px; @@ -3784,4 +3784,4 @@ input:checked + .toggleSwitchSlider:before { /* Class which is applied when the spinner image is spinning. */ .rotating { animation: rotating 10s linear infinite; -} \ No newline at end of file +} From 35815d44d78f4cb3052915c0c106891e01390778 Mon Sep 17 00:00:00 2001 From: Star <46096192+norhu1130@users.noreply.github.com> Date: Wed, 13 Jan 2021 13:13:48 +0900 Subject: [PATCH 3/4] Update index.js --- index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index f35657ba2f..9142845d9d 100644 --- a/index.js +++ b/index.js @@ -125,7 +125,7 @@ ipcMain.on('openMSALoginWindow', (ipcEvent, args) => { }) MSALoginWindow.removeMenu() - MSALoginWindow.loadURL('https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=a621aefe-b326-4c67-8688-34746ccd9bd2&response_type=code&scope=XboxLive.signin%20offline_access&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient') + MSALoginWindow.loadURL('https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=client_idhere&response_type=code&scope=XboxLive.signin%20offline_access&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient') }) @@ -274,4 +274,4 @@ app.on('activate', () => { if (win === null) { createWindow() } -}) \ No newline at end of file +}) From 542f105edf8402e185f107244b1e98ac660b107c Mon Sep 17 00:00:00 2001 From: Star <46096192+norhu1130@users.noreply.github.com> Date: Fri, 22 Jan 2021 20:22:55 +0900 Subject: [PATCH 4/4] Fixed Fix https://github.com/dscalzi/HeliosLauncher/pull/135#issuecomment-765185445 --- app/assets/js/authmanager.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/js/authmanager.js b/app/assets/js/authmanager.js index 3a094a3999..15d6d41990 100644 --- a/app/assets/js/authmanager.js +++ b/app/assets/js/authmanager.js @@ -38,7 +38,7 @@ async function validateSelectedMojang() { loggerSuccess.log('Account access token validated.') return true } -} +} else return true async function validateSelectedMicrosoft() { try { @@ -67,7 +67,7 @@ async function validateSelectedMicrosoft() { } catch (error) { return Promise.reject(error) } -} +} else return true // Exports // Functions @@ -160,4 +160,4 @@ exports.addMSAccount = async authCode => { } catch(error) { return Promise.reject(error) } -} \ No newline at end of file +}