Google authentication

This commit is contained in:
Geomitron
2020-08-30 18:55:03 -04:00
parent 4878d43919
commit 442cccb271
12 changed files with 857 additions and 24 deletions

View File

@@ -19,7 +19,7 @@
<h3 class="ui header">Downloads</h3>
<div class="ui form">
<div class="field">
<div *ngIf="loginAvailable" class="field">
<label>Google rate limit delay</label>
<div id="rateLimitInput" class="ui right labeled input">
<input type="number" [value]="settingsService.rateLimitDelay" (input)="changeRateLimit($event)">
@@ -28,12 +28,21 @@
</div>
</div>
</div>
<div *ngIf="loginAvailable" class="field">
<div class="ui button" data-tooltip="Removes rate limit delay" data-position="right center" (click)="googleLogin()">
<i class="google icon"></i>Sign in with Google
</div>
</div>
<div *ngIf="!loginAvailable" class="field">
<div class="ui button" (click)="googleLogout()">
<i class="google icon"></i>Sign out
</div>
</div>
</div>
<div *ngIf="settingsService.rateLimitDelay < 30" class="ui warning message">
<i class="exclamation circle icon"></i>
<b>Warning:</b> downloading files from Google with a delay less than about 30 seconds will eventually cause Google to
refuse download requests from this program for a few hours. This can be avoided by authenticating with your Google account.
(this will be possible in a future update to Bridge)
</div>
<!-- <h3 class="ui header">Theme</h3>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, ChangeDetectorRef } from '@angular/core'
import { Component, OnInit, AfterViewInit, ChangeDetectorRef } from '@angular/core'
import { ElectronService } from 'src/app/core/services/electron.service'
import { SettingsService } from 'src/app/core/services/settings.service'
@@ -12,6 +12,8 @@ export class SettingsComponent implements OnInit, AfterViewInit {
cacheSize = 'Calculating...'
updateAvailable = false
loginAvailable = true
loginClicked = false
downloadUpdateText = 'Update available'
updateDownloading = false
updateDownloaded = false
@@ -35,6 +37,10 @@ export class SettingsComponent implements OnInit, AfterViewInit {
this.updateAvailable = isAvailable
this.ref.detectChanges()
})
this.electronService.invoke('get-auth-status', undefined).then(isAuthenticated => {
this.loginAvailable = !isAuthenticated
this.ref.detectChanges()
})
}
ngAfterViewInit() {
@@ -64,6 +70,19 @@ export class SettingsComponent implements OnInit, AfterViewInit {
}
}
async googleLogin() {
if (this.loginClicked) { return }
this.loginClicked = true
const isAuthenticated = await this.electronService.invoke('google-login', undefined)
this.loginAvailable = !isAuthenticated
this.loginClicked = false
}
async googleLogout() {
this.loginAvailable = true
await this.electronService.invoke('google-logout', undefined)
}
openLibraryDirectory() {
this.electronService.openFolder(this.settingsService.libraryDirectory)
}

View File

@@ -1,4 +1,4 @@
import { FileDownloader } from './FileDownloader'
import { FileDownloader, getDownloader } from './FileDownloader'
import { join, parse } from 'path'
import { FileExtractor } from './FileExtractor'
import { sanitizeFilename, interpolate } from '../../shared/UtilFunctions'
@@ -140,7 +140,10 @@ export class ChartDownload {
// DOWNLOAD FILES
for (let i = 0; i < this.files.length; i++) {
if (this.files[i].name == 'ch.dat') { continue }
const downloader = new FileDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name))
let wasCanceled = false
this.cancelFn = () => { wasCanceled = true }
const downloader = await getDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name))
if (wasCanceled) { return }
this.cancelFn = () => downloader.cancelDownload()
const downloadComplete = this.addDownloadEventListeners(downloader, i)
@@ -194,23 +197,25 @@ export class ChartDownload {
*/
private addDownloadEventListeners(downloader: FileDownloader, fileIndex: number) {
let downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})`
let downloadStartPoint = 0 // How far into the individual file progress portion the download progress starts
let fileProgress = 0
downloader.on('waitProgress', (remainingSeconds: number, totalSeconds: number) => {
downloadStartPoint = this.individualFileProgressPortion / 2
this.percent = this._allFilesProgress + interpolate(remainingSeconds, totalSeconds, 0, 0, this.individualFileProgressPortion / 2)
this.updateGUI(downloadHeader, `Waiting for Google rate limit... (${remainingSeconds}s)`, 'good')
})
downloader.on('requestSent', () => {
fileProgress = this.individualFileProgressPortion / 2
fileProgress = downloadStartPoint
this.percent = this._allFilesProgress + fileProgress
this.updateGUI(downloadHeader, 'Sending request...', 'good')
})
downloader.on('downloadProgress', (bytesDownloaded) => {
downloader.on('downloadProgress', (bytesDownloaded: number) => {
downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})`
const size = Number(this.files[fileIndex].size)
fileProgress = interpolate(bytesDownloaded, 0, size, this.individualFileProgressPortion / 2, this.individualFileProgressPortion)
fileProgress = interpolate(bytesDownloaded, 0, size, downloadStartPoint, this.individualFileProgressPortion)
this.percent = this._allFilesProgress + fileProgress
this.updateGUI(downloadHeader, `Downloading... (${Math.round(1000 * bytesDownloaded / size) / 10}%)`, 'fastUpdate')
})

View File

@@ -1,9 +1,19 @@
import { AnyFunction } from '../../shared/UtilFunctions'
import { createWriteStream } from 'fs'
import * as needle from 'needle'
import { Readable } from 'stream'
// TODO: replace needle with got (for cancel() method) (if before-headers event is possible?)
import { googleTimer } from './GoogleTimer'
import { DownloadError } from './ChartDownload'
import { googleAuth } from '../google/GoogleAuth'
import { google } from 'googleapis'
import Bottleneck from 'bottleneck'
const drive = google.drive('v3')
const limiter = new Bottleneck({
minTime: 200 // Wait 200 ms between API requests
})
const RETRY_MAX = 2
type EventCallback = {
'waitProgress': (remainingSeconds: number, totalSeconds: number) => void
@@ -15,12 +25,157 @@ type EventCallback = {
'complete': () => void
}
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
export type FileDownloader = APIFileDownloader | SlowFileDownloader
const downloadErrors = {
timeout: (type: string) => { return { header: 'Timeout', body: `The download server could not be reached. (type=${type})` } },
connectionError: (err: Error) => { return { header: 'Connection Error', body: `${err.name}: ${err.message}` } },
responseError: (statusCode: number) => { return { header: 'Connection failed', body: `Server returned status code: ${statusCode}` } },
htmlError: () => { return { header: 'Invalid response', body: 'Download server returned HTML instead of a file.' } }
htmlError: () => { return { header: 'Invalid response', body: 'Download server returned HTML instead of a file.' } },
linkError: (url: string) => { return { header: 'Invalid link', body: `The download link is not formatted correctly: ${url}` } }
}
/**
* Downloads a file from `url` to `fullPath`.
* Will handle google drive virus scan warnings. Provides event listeners for download progress.
* On error, provides the ability to retry.
* Will only send download requests once every `getSettings().rateLimitDelay` seconds if a Google account has not been authenticated.
* @param url The download link.
* @param fullPath The full path to where this file should be stored (including the filename).
*/
export async function getDownloader(url: string, fullPath: string): Promise<FileDownloader> {
if (await googleAuth.attemptToAuthenticate()) {
return new APIFileDownloader(url, fullPath)
} else {
return new SlowFileDownloader(url, fullPath)
}
}
/**
* Downloads a file from `url` to `fullPath`.
* On error, provides the ability to retry.
*/
class APIFileDownloader {
private readonly URL_REGEX = /uc\?id=([^&]*)&export=download/u
private callbacks = {} as Callbacks
private retryCount: number
private wasCanceled = false
private fileID: string
private downloadStream: Readable
/**
* @param url The download link.
* @param fullPath The full path to where this file should be stored (including the filename).
*/
constructor(private url: string, private fullPath: string) {
// url looks like: "https://drive.google.com/uc?id=1TlxtOZlVgRgX7-1tyW0d5QzXVfL-MC3Q&export=download"
this.fileID = this.URL_REGEX.exec(url)[1]
}
/**
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
*/
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
this.callbacks[event] = callback
}
/**
* Download the file after waiting for the google rate limit.
*/
beginDownload() {
if (this.fileID == undefined) {
this.failDownload(downloadErrors.linkError(this.url))
}
this.startDownloadStream()
}
/**
* Uses the Google Drive API to start a download stream for the file with `this.fileID`.
*/
private startDownloadStream() {
limiter.schedule(this.cancelable(async () => {
this.callbacks.requestSent()
try {
this.downloadStream = (await drive.files.get({
fileId: this.fileID,
alt: 'media'
}, {
responseType: 'stream'
})).data
if (this.wasCanceled) { return }
this.handleDownloadResponse()
} catch (err) {
this.retryCount++
if (this.retryCount <= RETRY_MAX) {
console.log(`Failed to get file: Retry attempt ${this.retryCount}...`)
if (this.wasCanceled) { return }
this.startDownloadStream()
} else {
console.log(err)
this.failDownload(downloadErrors.responseError(err ? (err.code ?? 'unknown') : 'unknown'))
}
}
}))
}
/**
* Pipes the data from a download response to `this.fullPath`.
* @param req The download request.
*/
private handleDownloadResponse() {
this.callbacks.downloadProgress(0)
let downloadedSize = 0
try {
this.downloadStream.pipe(createWriteStream(this.fullPath))
} catch (err) {
this.failDownload(downloadErrors.connectionError(err))
}
this.downloadStream.on('data', this.cancelable((chunk: Buffer) => {
downloadedSize += chunk.length
this.callbacks.downloadProgress(downloadedSize)
}))
this.downloadStream.on('error', this.cancelable((err: Error) => {
this.failDownload(downloadErrors.connectionError(err))
}))
this.downloadStream.on('end', this.cancelable(() => {
this.callbacks.complete()
}))
}
/**
* Display an error message and provide a function to retry the download.
*/
private failDownload(error: DownloadError) {
this.callbacks.error(error, this.cancelable(() => this.beginDownload()))
}
/**
* Stop the process of downloading the file. (no more events will be fired after this is called)
*/
cancelDownload() {
this.wasCanceled = true
googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
if (this.downloadStream) {
this.downloadStream.destroy()
}
}
/**
* Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
*/
private cancelable<F extends AnyFunction>(fn: F) {
return (...args: Parameters<F>): ReturnType<F> => {
if (this.wasCanceled) { return }
return fn(...Array.from(args))
}
}
}
/**
@@ -29,8 +184,7 @@ const downloadErrors = {
* On error, provides the ability to retry.
* Will only send download requests once every `getSettings().rateLimitDelay` seconds.
*/
export class FileDownloader {
private readonly RETRY_MAX = 2
class SlowFileDownloader {
private callbacks = {} as Callbacks
private retryCount: number
@@ -82,7 +236,7 @@ export class FileDownloader {
this.req.on('timeout', this.cancelable((type: string) => {
this.retryCount++
if (this.retryCount <= this.RETRY_MAX) {
if (this.retryCount <= RETRY_MAX) {
console.log(`TIMEOUT: Retry attempt ${this.retryCount}...`)
this.requestDownload(cookieHeader)
} else {

View File

@@ -0,0 +1,62 @@
import * as http from 'http'
import { URL } from 'url'
import { REDIRECT_PATH, REDIRECT_BASE, SERVER_PORT } from '../../shared/Paths'
type EventCallback = {
'listening': () => void
'authCode': (authCode: string) => Promise<void>
}
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
class AuthServer {
private server: http.Server
private callbacks = {} as Callbacks
private connections = {}
/**
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
*/
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
this.callbacks[event] = callback
}
/**
* Starts listening on `SERVER_PORT` for the authentication callback.
* Emits the 'listening' event when the server is ready to listen.
* Emits the 'authCode' event when the callback request provides the authentication code.
*/
startServer() {
if (this.server != null) {
this.callbacks.listening()
} else {
this.server = http.createServer(this.requestListener.bind(this))
this.server.on('connection', (conn) => {
const key = conn.remoteAddress + ':' + conn.remotePort
this.connections[key] = conn
conn.on('close', () => delete this.connections[key])
})
this.server.listen(SERVER_PORT, () => this.callbacks.listening())
}
}
private requestListener(req: http.IncomingMessage, res: http.ServerResponse) {
if (req.url.includes(REDIRECT_PATH)) {
const searchParams = new URL(req.url, REDIRECT_BASE).searchParams
res.end()
this.destroyServer()
this.callbacks.authCode(searchParams.get('code'))
}
}
private destroyServer() {
this.server.close()
for (const key in this.connections) {
this.connections[key].destroy()
}
this.server = null
}
}
export const authServer = new AuthServer()

View File

@@ -0,0 +1,134 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
/* eslint-disable @typescript-eslint/camelcase */
import { dataPath, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI } from '../../shared/Paths'
import { mainWindow } from '../../main'
import { join } from 'path'
import { readFile, writeFile } from 'jsonfile'
import { google } from 'googleapis'
import { authServer } from './AuthServer'
import { BrowserWindow } from 'electron'
import * as fs from 'fs'
import { promisify } from 'util'
const unlink = promisify(fs.unlink)
const TOKEN_PATH = join(dataPath, 'token.json')
export class GoogleAuth {
private hasTriedTokenFile = false
private hasAuthenticated = false
/**
* Attempts to authenticate the googleapis library using the token stored at `TOKEN_PATH`.
* @returns `true` if the user is authenticated, and `false` otherwise.
*/
async attemptToAuthenticate() {
if (this.hasTriedTokenFile) {
return this.hasAuthenticated
}
const token = await this.getStoredToken()
if (token != null) {
// Token has been restored from a previous session
const oAuth2Client = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI)
oAuth2Client.setCredentials(token)
google.options({ auth: oAuth2Client })
this.hasAuthenticated = true
return true
} else {
// Token doesn't exist; user has not authenticated
this.hasAuthenticated = false
return false
}
}
async generateAuthToken() {
if (await this.getStoredToken() != null) { return true }
if (this.hasTriedTokenFile == false) {
// Token exists but couldn't be read
console.log('Auth token exists but could not be loaded. Check file permissions.')
return false
}
const oAuth2Client = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI)
let popupWindow: BrowserWindow
let gotAuthCode = false
return new Promise<boolean>(resolve => {
authServer.on('listening', () => {
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['profile', 'email', 'https://www.googleapis.com/auth/drive.readonly'],
redirect_uri: REDIRECT_URI
})
popupWindow = new BrowserWindow({
fullscreenable: false,
modal: true,
maximizable: false,
minimizable: false,
show: false,
parent: mainWindow,
autoHideMenuBar: true,
center: true,
thickFrame: true,
useContentSize: true,
width: 400,
})
popupWindow.loadURL(authUrl, { userAgent: 'Chrome' })
popupWindow.on('ready-to-show', () => popupWindow.show())
popupWindow.on('closed', () => resolve(gotAuthCode))
})
authServer.on('authCode', async (authCode) => {
const { tokens } = await oAuth2Client.getToken(authCode)
oAuth2Client.setCredentials(tokens)
google.options({ auth: oAuth2Client })
await writeFile(TOKEN_PATH, tokens)
this.hasTriedTokenFile = false
gotAuthCode = true
popupWindow.close()
})
authServer.startServer()
})
}
/**
* @returns the previously stored auth token, or `null` if it doesn't exist or can't be accessed.
*/
private async getStoredToken() {
this.hasTriedTokenFile = true
try {
return await readFile(TOKEN_PATH)
} catch (err) {
if (err && err.code && err.code != 'ENOENT') {
// Failed to access the file; next attempt should try again
this.hasTriedTokenFile = false
}
return null
}
}
/**
* removes the previously stored auth token.
*/
async deleteStoredToken() {
this.hasTriedTokenFile = false
this.hasAuthenticated = false
try {
await unlink(TOKEN_PATH)
} catch (err) {
console.log('Failed to delete token.')
return
}
}
}
export const googleAuth = new GoogleAuth()

View File

@@ -0,0 +1,56 @@
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { googleAuth } from './GoogleAuth'
/**
* Handles the 'google-login' event.
*/
class GoogleLoginHandler implements IPCInvokeHandler<'google-login'> {
event: 'google-login' = 'google-login'
/**
* @returns `true` if the user has been authenticated.
*/
async handler() {
return new Promise<boolean>(resolve => {
googleAuth.generateAuthToken().then((isLoggedIn) => resolve(isLoggedIn))
})
}
}
export const googleLoginHandler = new GoogleLoginHandler()
/**
* Handles the 'google-login' event.
*/
class GoogleLogoutHandler implements IPCInvokeHandler<'google-logout'> {
event: 'google-logout' = 'google-logout'
/**
* @returns `true` if the user has been authenticated.
*/
async handler() {
return new Promise<undefined>(resolve => {
googleAuth.deleteStoredToken().then(() => resolve())
})
}
}
export const googleLogoutHandler = new GoogleLogoutHandler()
/**
* Handles the 'get-auth-status' event.
*/
class GetAuthStatusHandler implements IPCInvokeHandler<'get-auth-status'> {
event: 'get-auth-status' = 'get-auth-status'
/**
* @returns `true` if the user is authenticated with Google.
*/
handler() {
return new Promise<boolean>(resolve => {
googleAuth.attemptToAuthenticate().then(isAuthenticated => resolve(isAuthenticated))
})
}
}
export const getAuthStatusHandler = new GetAuthStatusHandler()

View File

@@ -10,7 +10,7 @@ import { getIPCInvokeHandlers, getIPCEmitHandlers, IPCEmitEvents } from './share
import { getSettingsHandler } from './ipc/SettingsHandler.ipc'
import { dataPath } from './shared/Paths'
let mainWindow: BrowserWindow
export let mainWindow: BrowserWindow
const args = process.argv.slice(1)
const isDevBuild = args.some(val => val == '--dev')

View File

@@ -8,6 +8,7 @@ import { downloadHandler } from '../ipc/download/DownloadHandler'
import { Settings } from './Settings'
import { batchSongDetailsHandler } from '../ipc/browse/BatchSongDetailsHandler.ipc'
import { getSettingsHandler, setSettingsHandler } from '../ipc/SettingsHandler.ipc'
import { googleLoginHandler, getAuthStatusHandler, googleLogoutHandler } from '../ipc/google/GoogleLoginHandler.ipc'
import { UpdateProgress, getCurrentVersionHandler, downloadUpdateHandler, quitAndInstallHandler, getUpdateAvailableHandler } from '../ipc/UpdateHandler.ipc'
import { UpdateInfo } from 'electron-updater'
@@ -27,7 +28,10 @@ export function getIPCInvokeHandlers(): IPCInvokeHandler<keyof IPCInvokeEvents>[
batchSongDetailsHandler,
albumArtHandler,
getCurrentVersionHandler,
getUpdateAvailableHandler
getUpdateAvailableHandler,
googleLoginHandler,
googleLogoutHandler,
getAuthStatusHandler
]
}
@@ -63,6 +67,18 @@ export type IPCInvokeEvents = {
input: undefined
output: boolean
}
'google-login': {
input: undefined
output: boolean
}
'google-logout': {
input: undefined
output: undefined
}
'get-auth-status': {
input: undefined
output: boolean
}
}
/**

View File

@@ -9,4 +9,12 @@ export const tempPath = join(dataPath, 'temp')
export const themesPath = join(dataPath, 'themes')
// URL
export const serverURL = 'bridge-db.net'
export const serverURL = 'bridge-db.net'
// Google Project ID (More info on why these are here: https://developers.google.com/identity/protocols/oauth2#installed)
export const CLIENT_ID = '668064259105-vkm77i5lcoo2oumk2eulik7bae8k5agf.apps.googleusercontent.com'
export const CLIENT_SECRET = 'RU69Ubr9CidGcI0Z23Ttn2ZV'
export const SERVER_PORT = 42813
export const REDIRECT_BASE = `http://127.0.0.1:${SERVER_PORT}`
export const REDIRECT_PATH = `/oauth2callback`
export const REDIRECT_URI = `${REDIRECT_BASE}${REDIRECT_PATH}`