From e7e6f2b86b0a7a94a91d0bdda65f1d1cd7313e1d Mon Sep 17 00:00:00 2001 From: Dmitry Bushev Date: Tue, 6 Jul 2021 13:00:38 +0300 Subject: [PATCH] User Authentication in IDE (https://github.com/enso-org/ide/pull/1653) Add authentication and minimum supported version check Original commit: https://github.com/enso-org/ide/commit/2e2a804b1f5999ceafbcf7b31c417ff5f45085b1 --- ide/.github/workflows/gui-ci.yml | 5 + ide/CHANGELOG.md | 12 + ide/build/workflow.js | 10 +- ide/config.json | 3 + ide/src/js/lib/client/src/index.js | 24 +- ide/src/js/lib/content/firebase.yaml | 7 + ide/src/js/lib/content/package.js | 2 + ide/src/js/lib/content/src/index.html | 3 + ide/src/js/lib/content/src/index.ts | 373 ++++++++++++++++++++++- ide/src/js/lib/content/src/style.css | 22 ++ ide/src/js/lib/content/webpack.config.js | 1 + 11 files changed, 447 insertions(+), 15 deletions(-) create mode 100644 ide/config.json create mode 100644 ide/src/js/lib/content/firebase.yaml diff --git a/ide/.github/workflows/gui-ci.yml b/ide/.github/workflows/gui-ci.yml index f57421e56e24..28d11483b332 100644 --- a/ide/.github/workflows/gui-ci.yml +++ b/ide/.github/workflows/gui-ci.yml @@ -254,6 +254,7 @@ jobs: CSC_IDENTITY_AUTO_DISCOVERY: true APPLEID: ${{secrets.APPLE_NOTARIZATION_USERNAME}} APPLEIDPASS: ${{secrets.APPLE_NOTARIZATION_PASSWORD}} + FIREBASE_API_KEY: ${{secrets.FIREBASE_API_KEY}} run: node ./run dist --skip-version-validation --target macos if: startsWith(matrix.os,'macos') build: @@ -330,15 +331,19 @@ jobs: CSC_IDENTITY_AUTO_DISCOVERY: true APPLEID: ${{secrets.APPLE_NOTARIZATION_USERNAME}} APPLEIDPASS: ${{secrets.APPLE_NOTARIZATION_PASSWORD}} + FIREBASE_API_KEY: ${{secrets.FIREBASE_API_KEY}} run: node ./run dist --skip-version-validation --target macos if: startsWith(matrix.os,'macos') - name: Build (win) env: WIN_CSC_LINK: ${{secrets.MICROSOFT_CODE_SIGNING_CERT}} WIN_CSC_KEY_PASSWORD: ${{secrets.MICROSOFT_CODE_SIGNING_CERT_PASSWORD}} + FIREBASE_API_KEY: ${{secrets.FIREBASE_API_KEY}} run: node ./run dist --skip-version-validation --target win if: startsWith(matrix.os,'windows') - name: Build (linux) + env: + FIREBASE_API_KEY: ${{secrets.FIREBASE_API_KEY}} run: node ./run dist --skip-version-validation --target linux if: startsWith(matrix.os,'ubuntu') - name: Upload Content Artifacts diff --git a/ide/CHANGELOG.md b/ide/CHANGELOG.md index ca039ab7e932..8aafb95028a8 100644 --- a/ide/CHANGELOG.md +++ b/ide/CHANGELOG.md @@ -1,13 +1,25 @@ # Enso 2.0.0-alpha.7 (2021-06-06) +
![New Features](/docs/assets/tags/new_features.svg) + #### Visual Environment +- [User Authentication][1653]. Users can sign in to Enso using Google, GitHub or + email accounts. +
![Bug Fixes](/docs/assets/tags/bug_fixes.svg) +#### Visual Environment + - [Fix node selection bug ][1664]. Fix nodes not being deselected correctly in some circumstances. This would lead to nodes moving too fast when dragged [1650] or the internal state of the project being inconsistent [1626]. +[1653]: https://github.com/enso-org/ide/pull/1653 +[1664]: https://github.com/enso-org/ide/pull/1664 + +
+ # Enso 2.0.0-alpha.6 (2021-06-28)
![New Features](/docs/assets/tags/new_features.svg) diff --git a/ide/build/workflow.js b/ide/build/workflow.js index 8dfdd76318f8..1ba3352e44fd 100644 --- a/ide/build/workflow.js +++ b/ide/build/workflow.js @@ -172,14 +172,18 @@ buildOnMacOS = buildOn('macos', 'macos', { CSC_LINK: '${{secrets.APPLE_CODE_SIGNING_CERT}}', CSC_KEY_PASSWORD: '${{secrets.APPLE_CODE_SIGNING_CERT_PASSWORD}}', CSC_IDENTITY_AUTO_DISCOVERY: true, - APPLEID:'${{secrets.APPLE_NOTARIZATION_USERNAME}}', - APPLEIDPASS:'${{secrets.APPLE_NOTARIZATION_PASSWORD}}', + APPLEID: '${{secrets.APPLE_NOTARIZATION_USERNAME}}', + APPLEIDPASS: '${{secrets.APPLE_NOTARIZATION_PASSWORD}}', + FIREBASE_API_KEY: '${{secrets.FIREBASE_API_KEY}}', }) buildOnWindows = buildOn('win', 'windows', { WIN_CSC_LINK: '${{secrets.MICROSOFT_CODE_SIGNING_CERT}}', WIN_CSC_KEY_PASSWORD: '${{secrets.MICROSOFT_CODE_SIGNING_CERT_PASSWORD}}', + FIREBASE_API_KEY: '${{secrets.FIREBASE_API_KEY}}', +}) +buildOnLinux = buildOn('linux', 'ubuntu', { + FIREBASE_API_KEY: '${{secrets.FIREBASE_API_KEY}}', }) -buildOnLinux = buildOn('linux', 'ubuntu') let lintMarkdown = { name: "Lint Markdown sources", diff --git a/ide/config.json b/ide/config.json new file mode 100644 index 000000000000..12a9d28d022f --- /dev/null +++ b/ide/config.json @@ -0,0 +1,3 @@ +{ + "minimumSupportedVersion": "2.0.0-alpha.6" +} diff --git a/ide/src/js/lib/client/src/index.js b/ide/src/js/lib/client/src/index.js index c6a11d5aedc5..4cb4733fd2f6 100644 --- a/ide/src/js/lib/client/src/index.js +++ b/ide/src/js/lib/client/src/index.js @@ -53,6 +53,14 @@ function capitalizeFirstLetter(string) { const execFile = util.promisify(child_process.execFile); +// The list of hosts that the app can access. They are required for +// user authentication to work. +const trustedHosts = [ + 'enso-org.firebaseapp.com', + 'accounts.google.com', + 'accounts.youtube.com', + 'github.com', +] // ===================== // === Option Parser === @@ -332,7 +340,7 @@ Electron.app.on('web-contents-created', (event,contents) => { Electron.app.on('web-contents-created', (event,contents) => { contents.on('will-navigate', (event,navigationUrl) => { const parsedUrl = new URL(navigationUrl) - if (parsedUrl.origin !== origin) { + if (parsedUrl.origin !== origin && !trustedHosts.includes(parsedUrl.host)) { event.preventDefault() console.error(`Prevented navigation to '${navigationUrl}'.`) } @@ -441,6 +449,7 @@ let mainWindow = null let origin = null async function main(args) { + setUserAgent() runBackend() console.log("Starting the IDE service.") if(args.server !== false) { @@ -462,6 +471,19 @@ async function main(args) { } } +/// Set custom user agent to fix the issue with Google authentication. +/// +/// Google authentication doesn't work with the default Electron user agent. And +/// Google is quite picky about the values you provide. For example, just +/// removing Electron occurrences from the default user agent doesn't work. This +/// user agent was chosen by trial and error as a stable one. +/// +/// https://github.com/firebase/firebase-js-sdk/issues/2478 +function setUserAgent() { + const agent = 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:70.0) Gecko/20100101 Firefox/70.0' + Electron.app.userAgentFallback = agent + Electron.session.defaultSession.setUserAgent(agent) +} function urlParamsFromObject(obj) { let params = [] for (let key in obj) { diff --git a/ide/src/js/lib/content/firebase.yaml b/ide/src/js/lib/content/firebase.yaml new file mode 100644 index 000000000000..f297335af6e2 --- /dev/null +++ b/ide/src/js/lib/content/firebase.yaml @@ -0,0 +1,7 @@ +authDomain: "enso-org.firebaseapp.com" +projectId: "enso-org" +storageBucket: "enso-org.appspot.com" +messagingSenderId: "451746386966" +appId: "1:451746386966:web:558a832abe486208d61137" +measurementId: "G-W11ZNCQ476" +clientId: "451746386966-u5piv17hgvnimpq5ic5p60liekcqmqmu.apps.googleusercontent.com" diff --git a/ide/src/js/lib/content/package.js b/ide/src/js/lib/content/package.js index 4492acabf3b8..bed495b498cf 100644 --- a/ide/src/js/lib/content/package.js +++ b/ide/src/js/lib/content/package.js @@ -7,6 +7,8 @@ let config = { }, dependencies: { "enso-studio-common": "1.0.0", + "firebase": "^8.6.8", + "firebaseui": "^4.8.0", "copy-webpack-plugin": "^5.1.1", "mixpanel-browser": "2.40.1" }, diff --git a/ide/src/js/lib/content/src/index.html b/ide/src/js/lib/content/src/index.html index 4be332c6c1d8..124ee319f88d 100644 --- a/ide/src/js/lib/content/src/index.html +++ b/ide/src/js/lib/content/src/index.html @@ -5,8 +5,10 @@ + + diff --git a/ide/src/js/lib/content/src/index.ts b/ide/src/js/lib/content/src/index.ts index 8b9fca13d449..c5e5a8e8d63c 100644 --- a/ide/src/js/lib/content/src/index.ts +++ b/ide/src/js/lib/content/src/index.ts @@ -13,6 +13,13 @@ import cfg from '../../../config' // @ts-ignore import assert from 'assert' +// @ts-ignore +import firebase from 'firebase/app' +// @ts-ignore +import 'firebase/auth' +// @ts-ignore +import semver from 'semver' + // ================== // === Global API === // ================== @@ -25,6 +32,9 @@ class ContentApi { assert(typeof config.no_data_gathering == 'boolean') if (!config.no_data_gathering) { this.logger = new MixpanelLogger() + if (ok(config.email)) { + this.logger.identify(config.email) + } } } remoteLog(event: string, data?: any) { @@ -217,6 +227,10 @@ class MixpanelLogger { } } + identify(uniqueId: string) { + this.mixpanel.identify(uniqueId) + } + static trim_message(message: string) { const MAX_MESSAGE_LENGTH = 500 let trimmed = message.substr(0, MAX_MESSAGE_LENGTH) @@ -356,7 +370,7 @@ function setupCrashDetection() { // (https://v8.dev/docs/stack-trace-api#compatibility) Error.stackTraceLimit = 100 - window.addEventListener('error', function (event) { + window.addEventListener('error', function(event) { // We prefer stack traces over plain error messages but not all browsers produce traces. if (ok(event.error) && ok(event.error.stack)) { handleCrash(event.error.stack) @@ -364,7 +378,7 @@ function setupCrashDetection() { handleCrash(event.message) } }) - window.addEventListener('unhandledrejection', function (event) { + window.addEventListener('unhandledrejection', function(event) { // As above, we prefer stack traces. // But here, `event.reason` is not even guaranteed to be an `Error`. handleCrash(event.reason.stack || event.reason.message || 'Unhandled rejection') @@ -443,6 +457,319 @@ async function reportCrash(message: string) { }) } +// ===================== +// === Version Check === +// ===================== + +// An error with the payload. +class ErrorDetails { + + public readonly message: string + public readonly payload: any + + constructor(message: string, payload: any) { + this.message = message + this.payload = payload + } +} + +/// Utility methods helping to work with the versions. +class Versions { + + /// Development version. + static devVersion = semver.coerce('0.0.0') + /// Version of the `client` js package. + static clientVersion = require('../../client/package.json').version + + /// Compare the application version with the minimum supported version and + /// return `true` if the application version is supported, i.e. greater or + /// equal than the minimum supported version. + static compare(minSupportedVersion: string, applicationVersion: string): boolean { + const appVersion = semver.clean(applicationVersion, { loose: true }) + if (ok(appVersion)) { + if (semver.eq(appVersion, Versions.devVersion)) { + return true + } else { + const minVersion = semver.clean(minSupportedVersion, { loose: true }) + if (ok(minVersion)) { + return semver.gte(appVersion, minVersion) + } else { + throw new ErrorDetails( + 'Failed to parse app version.', + { applicationVersion } + ) + } + } + } else { + throw new ErrorDetails( + 'Failed to parse minimum supported version.', + { minSupportedVersion } + ) + } + } + + static isDevVersion(): boolean { + const clientVersion = Versions.clientVersion + const appVersion = semver.clean(clientVersion, { loose: true }) + if (ok(appVersion)) { + return semver.eq(appVersion, Versions.devVersion) + } else { + console.error('Failed to parse applicationVersion.', { clientVersion }) + return false + } + } +} + +/// Fetch the application config from the provided url. +async function fetchApplicationConfig(url: string) { + const https = require('https') + const statusCodeOK = 200 + + return new Promise((resolve: any, reject: any) => { + https.get(url, (res: any) => { + const statusCode = res.statusCode + if (statusCode !== statusCodeOK) { + reject(new ErrorDetails('Request failed.', { url, statusCode })) + } + + res.setEncoding('utf8') + let rawData = '' + + res.on('data', (chunk: any) => rawData += chunk) + + res.on('end', () => { + try { + resolve(JSON.parse(rawData)) + } catch (e) { + reject(e) + } + }) + + res.on('error', (e: any) => reject(e)) + }) + }) +} + +/// Return `true` if the current application version is still supported +/// and `false` otherwise. +/// +/// Function downloads the application config containing the minimum supported +/// version from GitHub and compares it with the version of the `client` js +/// package. When the function is unable to download the application config, or +/// one of the compared versions does not match the semver scheme, it returns +/// `true`. +async function checkMinSupportedVersion(config: Config) { + try { + const appConfig: any = await fetchApplicationConfig(config.application_config_url) + const clientVersion = Versions.clientVersion + const minSupportedVersion = appConfig.minimumSupportedVersion + + console.log( + `Application version check.`, + { minSupportedVersion, clientVersion } + ) + return Versions.compare(minSupportedVersion, clientVersion) + } catch (e) { + console.error('Minimum version check failed.', e) + return true + } +} + +// ====================== +// === Authentication === +// ====================== + +class FirebaseAuthentication { + + protected readonly config: any + public readonly firebaseui: any + public readonly ui: any + + public authCallback: any + + constructor(authCallback: any) { + this.firebaseui = require('firebaseui') + this.config = this.getFirebaseConfig() + // initialize Firebase + firebase.initializeApp(this.config) + // create HTML markup + this.createHtml() + // initialize Firebase UI + this.ui = new this.firebaseui.auth.AuthUI(firebase.auth()) + this.ui.disableAutoSignIn() + this.authCallback = authCallback + firebase.auth().onAuthStateChanged((user: any) => { + if (ok(user)) { + if (this.hasEmailAuth(user) && !user.emailVerified) { + document.getElementById('user-email-not-verified').style.display = 'block' + this.handleSignedOutUser() + } else { + this.handleSignedInUser(user) + } + } else { + this.handleSignedOutUser() + } + }) + } + + protected getFirebaseConfig() { + const config = require('../firebase.yaml') + // @ts-ignore + const firebaseApiKey = FIREBASE_API_KEY + if (ok(firebaseApiKey) && firebaseApiKey.length > 0) { + // @ts-ignore + config['apiKey'] = firebaseApiKey + return config + } else { + throw new Error('Empty FIREBASE_API_KEY') + } + } + + protected hasEmailAuth(user: any): boolean { + const emailProviderId = firebase.auth.EmailAuthProvider.PROVIDER_ID + const hasEmailProvider = user + .providerData + .some((data: any) => data.providerId === emailProviderId) + const hasOneProvider = user.providerData.length === 1 + return hasOneProvider && hasEmailProvider + } + + protected getUiConfig() { + return { + 'callbacks': { + // Called when the user has been successfully signed in. + 'signInSuccessWithAuthResult': (authResult: any, redirectUrl: any) => { + if (ok(authResult.user)) { + switch (authResult.additionalUserInfo.providerId) { + case firebase.auth.EmailAuthProvider.PROVIDER_ID: + if (authResult.user.emailVerified) { + this.handleSignedInUser(authResult.user) + } else { + authResult.user.sendEmailVerification() + document.getElementById('user-email-not-verified').style.display = 'block' + this.handleSignedOutUser() + } + break; + + default: + } + } + // Do not redirect. + return false; + } + }, + 'signInOptions': [ + { + provider: firebase.auth.GoogleAuthProvider.PROVIDER_ID, + // Required to enable ID token credentials for this provider. + clientId: this.config.clientId + }, + firebase.auth.GithubAuthProvider.PROVIDER_ID, + { + provider: firebase.auth.EmailAuthProvider.PROVIDER_ID, + // Whether the display name should be displayed in Sign Up page. + requireDisplayName: false, + } + ], + } + } + + protected handleSignedOutUser() { + document.getElementById('auth-container').style.display = 'block' + this.ui.start('#firebaseui-container', this.getUiConfig()) + } + + protected handleSignedInUser(user: any) { + document.getElementById('auth-container').style.display = 'none' + this.authCallback(user) + } + + /// Create the HTML markup. + /// + /// ``` + ///
+ ///
+ ///
+ ///

Sign in to Enso

+ ///
+ ///

Enso lets you create interactive data workflows. In order to share them, you need an account. In alpha/beta versions, this account is required.

+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// Verification link is sent. You can sign in after verifying your email. + ///
+ ///
+ ///
+ /// This version is no longer supported. Please download a new one. + ///
+ ///
+ /// ``` + protected createHtml() { + const authContainer = 'auth-container' + const authInfo = 'auth-info' + const authHeader = 'auth-header' + const authText = 'auth-text' + const firebaseuiContainer = 'firebaseui-container' + const userSignedOut = 'user-signed-out' + const userEmailNotVerified = 'user-email-not-verified' + const versionCheck = 'version-check' + + const authHeaderText = document.createTextNode('Sign in to Enso') + const authTextText = document.createTextNode('Enso lets you create interactive data workflows. In order to share them, you need an account. In alpha/beta versions, this account is required.') + const userEmailNotVerifiedText = document.createTextNode('Verification link is sent. You can sign in after verifying your email.') + const versionCheckText = document.createTextNode('This version is no longer supported. Please download a new one.') + + let root = document.getElementById('root') + + // div#auth-container + let authContainerDiv = document.createElement('div') + authContainerDiv.id = authContainer + authContainerDiv.style.display = 'none' + // div.auth-header + let authHeaderDiv = document.createElement('div') + authHeaderDiv.className = authHeader + // div.auth-header/h1 + let authHeaderH1 = document.createElement('h1') + authHeaderH1.appendChild(authHeaderText) + // div.auth-header/div#auth-text + let authHeaderTextDiv = document.createElement('div') + authHeaderTextDiv.className = authText + authHeaderTextDiv.appendChild(authTextText) + + authHeaderDiv.appendChild(authHeaderH1) + authHeaderDiv.appendChild(authHeaderTextDiv) + + // div#user-signed-out + let userSignedOutDiv = document.createElement('div') + userSignedOutDiv.id = userSignedOut + let firebaseuiContainerDiv = document.createElement('div') + firebaseuiContainerDiv.id = firebaseuiContainer + userSignedOutDiv.appendChild(firebaseuiContainerDiv) + + // div#user-email-not-verified + let userEmailNotVerifiedDiv = document.createElement('div') + userEmailNotVerifiedDiv.id = userEmailNotVerified + userEmailNotVerifiedDiv.className = authInfo + userEmailNotVerifiedDiv.appendChild(userEmailNotVerifiedText) + + authContainerDiv.appendChild(authHeaderDiv) + authContainerDiv.appendChild(userSignedOutDiv) + authContainerDiv.appendChild(userEmailNotVerifiedDiv) + + // div#version-check + let versionCheckDiv = document.createElement('div') + versionCheckDiv.id = versionCheck + versionCheckDiv.className = authInfo + versionCheckDiv.appendChild(versionCheckText) + + root.appendChild(authContainerDiv) + root.appendChild(versionCheckDiv) + } +} + // ======================== // === Main Entry Point === // ======================== @@ -488,6 +815,9 @@ class Config { public no_data_gathering: boolean public is_in_cloud: boolean public verbose: boolean + public authentication_enabled: boolean + public email: string + public application_config_url: string static default() { let config = new Config() @@ -498,6 +828,9 @@ class Config { config.no_data_gathering = false config.is_in_cloud = false config.entry = null + config.authentication_enabled = true + config.application_config_url = + 'https://raw.githubusercontent.com/enso-org/ide/develop/config.json' return config } @@ -567,15 +900,7 @@ function tryAsString(value: any): string { } /// Main entry point. Loads WASM, initializes it, chooses the scene to run. -API.main = async function (inputConfig: any) { - const urlParams = new URLSearchParams(window.location.search) - // @ts-ignore - const urlConfig = Object.fromEntries(urlParams.entries()) - - const config = Config.default() - config.updateFromObject(inputConfig) - config.updateFromObject(urlConfig) - +async function mainEntryPoint(config: any) { // @ts-ignore API[globalConfig.windowAppScopeConfigName] = config @@ -619,3 +944,29 @@ API.main = async function (inputConfig: any) { show_debug_screen(wasm, '') } } + +API.main = async function(inputConfig: any) { + const urlParams = new URLSearchParams(window.location.search) + // @ts-ignore + const urlConfig = Object.fromEntries(urlParams.entries()) + + const config = Config.default() + config.updateFromObject(inputConfig) + config.updateFromObject(urlConfig) + + if (await checkMinSupportedVersion(config)) { + if (config.authentication_enabled && !Versions.isDevVersion()) { + new FirebaseAuthentication( + function(user: any) { + config.email = user.email + mainEntryPoint(config) + }) + } else { + await mainEntryPoint(config) + } + } else { + // Display a message asking to update the application. + document.getElementById('auth-container').style.display = 'none' + document.getElementById('version-check').style.display = 'block' + } +} diff --git a/ide/src/js/lib/content/src/style.css b/ide/src/js/lib/content/src/style.css index 53b91fca1862..0dcee53c7739 100644 --- a/ide/src/js/lib/content/src/style.css +++ b/ide/src/js/lib/content/src/style.css @@ -33,6 +33,28 @@ body { pointer-events: auto; } +.auth-header { + font-family: sans-serif; + text-align: center; + margin: 24px auto; +} + +.auth-text { + text-align: justify; + font-family: sans-serif; + color: #454545; + width: 50%; + margin: 12px auto; +} + +.auth-info { + text-align: center; + font-family: sans-serif; + color: #454545; + margin: 24px auto; + display: none; +} + #crash-banner { background: DarkSalmon; color: #2c1007; diff --git a/ide/src/js/lib/content/webpack.config.js b/ide/src/js/lib/content/webpack.config.js index 44b5c1c96766..ceee0f080fb6 100644 --- a/ide/src/js/lib/content/webpack.config.js +++ b/ide/src/js/lib/content/webpack.config.js @@ -41,6 +41,7 @@ module.exports = { GIT_HASH: JSON.stringify(git('rev-parse HEAD')), GIT_STATUS: JSON.stringify(git('status --short --porcelain')), BUILD_INFO: JSON.stringify(BUILD_INFO), + FIREBASE_API_KEY: JSON.stringify(process.env.FIREBASE_API_KEY), }) ], devServer: {