Merge remote-tracking branch 'MS/master' into master

This commit is contained in:
Freshmilkymilk 2021-02-01 13:53:04 -07:00
commit 1f0008d444
No known key found for this signature in database
GPG Key ID: B210214726522691
7 changed files with 578 additions and 87 deletions

View File

@ -613,8 +613,7 @@ body, button {
font-weight: bold;
letter-spacing: 2px;
border: none;
padding: 15px 5px;
margin: 10px 0px;
margin: 15px 0px;
cursor: pointer;
position: relative;
right: -20px;
@ -859,6 +858,28 @@ body, button {
transform: rotate(45deg);
}
#loginMSButton {
background: none;
border: none;
margin-top: 5px;
margin-bottom: 10px;
font-size: 20px;
}
#loginMSButton:disabled {
color: rgba(255, 255, 255, 0.75);
pointer-events: none;
}
#loginMSButton:hover,
#loginMSButton:focus {
text-shadow: 0px 0px 20px #fff;
outline: none;
}
#loginMSButton:active {
color: #c7c7c7;
text-shadow: 0px 0px 20px #c7c7c7;
}
/*
#login_filter {
height: calc(100% - 22px);

View File

@ -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,36 @@ 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
} else 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 minecraftBuyed = await Microsoft.checkMCStore(MCAccessToken.access_token)
if(!minecraftBuyed)
return Promise.reject({
message: 'You didn\'t buy Minecraft! Please use another Microsoft account or buy Minecraft.'
})
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)
}
}

View File

@ -103,7 +103,8 @@ const DEFAULT_CONFIG = {
selectedServer: null, // Resolved
selectedAccount: null,
authenticationDatabase: {},
modConfigurations: []
modConfigurations: [],
microsoftAuth: {}
}
let config = null
@ -325,8 +326,9 @@ exports.getAuthAccount = function(uuid){
*
* @returns {Object} The authenticated account object created by this action.
*/
exports.updateAuthAccount = function(uuid, accessToken){
exports.updateAuthAccount = function(uuid, accessToken, expiresAt = undefined){
config.authenticationDatabase[uuid].accessToken = accessToken
config.authenticationDatabase[uuid].expiresAt = expiresAt
return config.authenticationDatabase[uuid]
}
@ -340,13 +342,15 @@ 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]
}
@ -685,4 +689,19 @@ exports.getAllowPrerelease = function(def = false){
*/
exports.setAllowPrerelease = function(allowPrerelease){
config.settings.launcher.allowPrerelease = allowPrerelease
}
}
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
}

225
app/assets/js/microsoft.js Normal file
View File

@ -0,0 +1,225 @@
// Requirements
const request = require('request')
// Constants
const clientId = 'client id here'
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 => {
if (response.body.XErr) {
switch (response.body.XErr) {
case 2148916233:
reject({
message: 'Your Microsoft account is not connected to an Xbox account. Please create one.<br>'
})
return
case 2148916238:
reject({
message: 'Since you are not yet 18 years old, an adult must add you to a family in order for you to use NexusLauncher!'
})
return
}
reject(response.body)
}
resolve(response.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.checkMCStore = async function(access_token){
return new Promise((resolve, reject) => {
request.get({
url: 'https://api.minecraftservices.com/entitlements/mcstore',
json: true,
headers: {
Authorization: 'Bearer ' + access_token
}
}, (err, res, body) => {
if (err) {
resolve(false)
return
}
if(body.items && body.items.length > 0) resolve(true)
else resolve(false)
})
})
}
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)
})
})
}

View File

@ -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,80 @@ loginButton.addEventListener('click', () => {
loggerLogin.log('Error while logging in.', err)
})
})
loginMSButton.addEventListener('click', (event) => {
loginMSButton.disabled = true
ipcRenderer.send('openMSALoginWindow', 'open')
})
ipcRenderer.on('MSALoginWindowReply', (event, ...args) => {
if (args[0] === 'error') {
setOverlayContent('ERROR', 'There is already a login window 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')
if(error === 'access_denied'){
error = 'ERRPR'
errorDesc = 'To use the NexusLauncher, you must agree to the required permissions! Otherwise you can\'t use this launcher with Microsoft accounts.<br><br>Despite agreeing to the permissions you don\'t give us the possibility to do anything with your account, because all data will always be sent back to you (the launcher) IMMEDIATELY and WITHOUT WAY.'
}
setOverlayContent(error, errorDesc, 'OK')
setOverlayHandler(() => {
loginMSButton.disabled = false
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 => {
loginMSButton.disabled = false
loginLoading(false)
setOverlayContent('ERROR', error.message ? error.message : 'An error occurred while logging in with Microsoft! For more detailed information please check the log. You can open it with CTRL + SHIFT + I.', Lang.queryJS('login.tryAgain'))
setOverlayHandler(() => {
formDisabled(false)
toggleOverlay(false)
})
toggleOverlay(true)
loggerLogin.error(error)
})
})

View File

@ -1,65 +1,99 @@
<div id="loginContainer" style="display: none;">
<div id="loginCancelContainer" style="display: none;">
<button id="loginCancelButton">
<div id="loginCancelIcon">X</div>
<span id="loginCancelText">Cancel</span>
</button>
</div>
<div id="loginContent">
<form id="loginForm">
<img id="loginImageSeal" src="assets/images/SealCircle.png"/>
<span id="loginSubheader">MINECRAFT LOGIN</span>
<div class="loginFieldContainer">
<svg id="profileSVG" class="loginSVG" viewBox="40 37 65.36 61.43">
<g>
<path d="M86.77,58.12A13.79,13.79,0,1,0,73,71.91,13.79,13.79,0,0,0,86.77,58.12M97,103.67a3.41,3.41,0,0,0,3.39-3.84,27.57,27.57,0,0,0-54.61,0,3.41,3.41,0,0,0,3.39,3.84Z"/>
</g>
</svg>
<span class="loginErrorSpan" id="loginEmailError">* Invalid Value</span>
<input id="loginUsername" class="loginField" type="text" placeholder="EMAIL OR USERNAME"/>
</div>
<div class="loginFieldContainer">
<svg id="lockSVG" class="loginSVG" viewBox="40 32 60.36 70.43">
<g>
<path d="M86.16,54a16.38,16.38,0,1,0-32,0H44V102.7H96V54Zm-25.9-3.39a9.89,9.89,0,1,1,19.77,0A9.78,9.78,0,0,1,79.39,54H60.89A9.78,9.78,0,0,1,60.26,50.59ZM70,96.2a6.5,6.5,0,0,1-6.5-6.5,6.39,6.39,0,0,1,3.1-5.4V67h6.5V84.11a6.42,6.42,0,0,1,3.39,5.6A6.5,6.5,0,0,1,70,96.2Z"/>
</g>
</svg>
<span class="loginErrorSpan" id="loginPasswordError">* Required</span>
<input id="loginPassword" class="loginField" type="password" placeholder="PASSWORD"/>
</div>
<div id="loginOptions">
<span class="loginSpanDim">
<a href="https://my.minecraft.net/en-us/password/forgot/">forgot password?</a>
</span>
<label id="checkmarkContainer">
<input id="loginRememberOption" type="checkbox" checked>
<span id="loginRememberText" class="loginSpanDim">remember me?</span>
<span class="loginCheckmark"></span>
</label>
</div>
<button id="loginButton" disabled>
<div id="loginButtonContent">
LOGIN
<svg id="loginSVG" viewBox="0 0 24.87 13.97">
<defs>
<style>.arrowLine{fill:none;stroke:#FFF;stroke-width:2px;transition: 0.25s ease;}</style>
</defs>
<polyline class="arrowLine" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
</svg>
<div class="circle-loader">
<div class="checkmark draw"></div>
</div>
<!--<div class="spinningCircle" id="loginSpinner"></div>-->
</div>
</button>
<div id="loginDisclaimer">
<span class="loginSpanDim" id="loginRegisterSpan">
<a href="https://minecraft.net/en-us/store/minecraft/">Need an Account?</a>
</span>
<p class="loginDisclaimerText">Your password is sent directly to mojang and never stored.</p>
<p class="loginDisclaimerText">Helios Launcher is not affiliated with Mojang AB.</p>
</div>
</form>
</div>
<script src="./assets/js/scripts/login.js"></script>
</div>
<div id="loginContainer" style="display: none">
<div id="loginCancelContainer" style="display: none">
<button id="loginCancelButton">
<div id="loginCancelIcon">X</div>
<span id="loginCancelText">Cancel</span>
</button>
</div>
<div id="loginContent">
<form id="loginForm">
<img id="loginImageSeal" src="assets/images/SealCircle.png" />
<span id="loginSubheader">MINECRAFT LOGIN</span>
<div class="loginFieldContainer">
<svg id="profileSVG" class="loginSVG" viewBox="40 37 65.36 61.43">
<g>
<path
d="M86.77,58.12A13.79,13.79,0,1,0,73,71.91,13.79,13.79,0,0,0,86.77,58.12M97,103.67a3.41,3.41,0,0,0,3.39-3.84,27.57,27.57,0,0,0-54.61,0,3.41,3.41,0,0,0,3.39,3.84Z"
/>
</g>
</svg>
<span class="loginErrorSpan" id="loginEmailError">* Invalid Value</span>
<input
id="loginUsername"
class="loginField"
type="text"
placeholder="EMAIL OR USERNAME"
/>
</div>
<div class="loginFieldContainer">
<svg id="lockSVG" class="loginSVG" viewBox="40 32 60.36 70.43">
<g>
<path
d="M86.16,54a16.38,16.38,0,1,0-32,0H44V102.7H96V54Zm-25.9-3.39a9.89,9.89,0,1,1,19.77,0A9.78,9.78,0,0,1,79.39,54H60.89A9.78,9.78,0,0,1,60.26,50.59ZM70,96.2a6.5,6.5,0,0,1-6.5-6.5,6.39,6.39,0,0,1,3.1-5.4V67h6.5V84.11a6.42,6.42,0,0,1,3.39,5.6A6.5,6.5,0,0,1,70,96.2Z"
/>
</g>
</svg>
<span class="loginErrorSpan" id="loginPasswordError">* Required</span>
<input
id="loginPassword"
class="loginField"
type="password"
placeholder="PASSWORD"
/>
</div>
<div id="loginOptions">
<span class="loginSpanDim">
<a href="https://my.minecraft.net/en-us/password/forgot/"
>forgot password?</a
>
</span>
<label id="checkmarkContainer">
<input id="loginRememberOption" type="checkbox" checked />
<span id="loginRememberText" class="loginSpanDim">remember me?</span>
<span class="loginCheckmark"></span>
</label>
</div>
<button id="loginButton" disabled>
<div id="loginButtonContent">
LOGIN
<svg id="loginSVG" viewBox="0 0 24.87 13.97">
<defs>
<style>
.arrowLine {
fill: none;
stroke: #fff;
stroke-width: 2px;
transition: 0.25s ease;
}
</style>
</defs>
<polyline
class="arrowLine"
points="0.71 13.26 12.56 1.41 24.16 13.02"
/>
</svg>
<div class="circle-loader">
<div class="checkmark draw"></div>
</div>
<!--<div class="spinningCircle" id="loginSpinner"></div>-->
</div>
</button>
oder <br />
<button id="loginMSButton">Microsoft Login</button>
<div id="loginDisclaimer">
<span class="loginSpanDim" id="loginRegisterSpan">
<a href="https://www.minecraft.net/en-us/store/minecraft-java-edition"
>Need an Account?</a
>
</span>
<p class="loginDisclaimerText">
Your password is sent directly to mojang and never stored.
</p>
<p class="loginDisclaimerText">
Helios Launcher is not affiliated with Mojang AB.
</p>
</div>
</form>
</div>
<script src="./assets/js/scripts/login.js"></script>
</div>

View File

@ -8,6 +8,9 @@ const path = require('path')
const semver = require('semver')
const url = require('url')
const redirectUriPrefix = 'https://login.microsoftonline.com/common/oauth2/nativeclient?'
const clientID = 'client id here'
// Setup auto updater.
function initAutoUpdater(event, data) {
@ -85,6 +88,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({
title: 'Microsoft-Login',
backgroundColor: '#222222',
width: 520,
height: 600,
frame: false,
icon: getPlatformIcon('SealCircle')
})
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?prompt=consent&client_id=' + clientID + '&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