fix #1359: fixed Google Drive login issues in desktop apps

This commit is contained in:
antelle 2020-03-17 19:45:38 +01:00
parent 61d83319d4
commit 7ccf38f0df
11 changed files with 387 additions and 78 deletions

View File

@ -1,4 +1,5 @@
import { AppSettingsModel } from 'models/app-settings-model';
import { UrlFormat } from 'util/formatting/url-format';
const ChooserAppKey = 'qp7ctun6qt5n9d6';
@ -26,8 +27,8 @@ DropboxChooser.prototype.choose = function() {
};
DropboxChooser.prototype.buildUrl = function() {
const urlParams = {
origin: encodeURIComponent(window.location.protocol + '//' + window.location.host),
return UrlFormat.makeUrl('https://www.dropbox.com/chooser', {
origin: window.location.protocol + '//' + window.location.host,
'app_key': AppSettingsModel.dropboxAppKey || ChooserAppKey,
'link_type': 'direct',
trigger: 'js',
@ -36,13 +37,7 @@ DropboxChooser.prototype.buildUrl = function() {
folderselect: 'false',
iframe: 'false',
version: 2
};
return (
'https://www.dropbox.com/chooser?' +
Object.keys(urlParams)
.map(key => key + '=' + urlParams[key])
.join('&')
);
});
};
DropboxChooser.prototype.onMessage = function(e) {

View File

@ -10,7 +10,8 @@ const Timeouts = {
RedrawInactiveWindow: 50,
PopupWaitTime: 1000,
AutoUpdatePluginsAfterStart: 500,
LinkDownloadRevoke: 10 * 1000 * 60
LinkDownloadRevoke: 10 * 1000 * 60,
DefaultHttpRequest: 60000
};
export { Timeouts };

View File

@ -211,18 +211,14 @@ class StorageDropbox extends StorageBase {
const host = args.host || 'api';
let headers;
let data = args.data;
let dataType;
if (args.apiArg) {
headers = {
'Dropbox-API-Arg': this._encodeJsonHttpHeader(JSON.stringify(args.apiArg))
};
if (args.data) {
headers['Content-Type'] = 'application/octet-stream';
}
} else if (args.data) {
data = JSON.stringify(data);
headers = {
'Content-Type': 'application/json'
};
dataType = 'application/json';
}
this._xhr({
url: `https://${host}.dropboxapi.com/2/${args.method}`,
@ -230,6 +226,7 @@ class StorageDropbox extends StorageBase {
responseType: args.responseType || 'json',
headers,
data,
dataType,
statuses: args.statuses || undefined,
success: args.success,
error: (e, xhr) => {

View File

@ -1,9 +1,19 @@
import { StorageBase } from 'storage/storage-base';
import { Locale } from 'util/locale';
import { Features } from 'util/features';
const GDriveClientId = {
Local: '783608538594-36tkdh8iscrq8t8dq87gghubnhivhjp5.apps.googleusercontent.com',
Production: '847548101761-koqkji474gp3i2gn3k5omipbfju7pbt1.apps.googleusercontent.com'
Production: '847548101761-koqkji474gp3i2gn3k5omipbfju7pbt1.apps.googleusercontent.com',
Desktop: '847548101761-h2pcl2p6m1tssnlqm0vrm33crlveccbr.apps.googleusercontent.com'
};
const GDriveClientSecret = {
// They are not really secrets and are supposed to be embedded in the app code according to
// the official guide: https://developers.google.com/identity/protocols/oauth2#installed
// The process results in a client ID and, in some cases, a client secret,
// which you embed in the source code of your application.
// (In this context, the client secret is obviously not treated as a secret.)
Desktop: 'nTSCiqXtUNmURIIdASaC1TJK'
};
const NewFileIdPrefix = 'NewFile:';
@ -95,46 +105,48 @@ class StorageGDrive extends StorageBase {
const ts = this.logger.ts();
const isNew = path.lastIndexOf(NewFileIdPrefix, 0) === 0;
let url;
let dataType;
let dataIsMultipart = false;
if (isNew) {
url =
this._baseUrlUpload +
'/files?uploadType=multipart&fields=id,headRevisionId';
const fileName = path.replace(NewFileIdPrefix, '') + '.kdbx';
const boundry = 'b' + Date.now() + 'x' + Math.round(Math.random() * 1000000);
data = new Blob(
[
'--',
boundry,
'\r\n',
'Content-Type: application/json; charset=UTF-8',
'\r\n\r\n',
JSON.stringify({ name: fileName }),
'\r\n',
'--',
boundry,
'\r\n',
'Content-Type: application/octet-stream',
'\r\n\r\n',
data,
'\r\n',
'--',
boundry,
'--',
'\r\n'
],
{ type: 'multipart/related; boundary="' + boundry + '"' }
);
const boundary = 'b' + Date.now() + 'x' + Math.round(Math.random() * 1000000);
data = [
'--',
boundary,
'\r\n',
'Content-Type: application/json; charset=UTF-8',
'\r\n\r\n',
JSON.stringify({ name: fileName }),
'\r\n',
'--',
boundary,
'\r\n',
'Content-Type: application/octet-stream',
'\r\n\r\n',
data,
'\r\n',
'--',
boundary,
'--',
'\r\n'
];
dataType = 'multipart/related; boundary="' + boundary + '"';
dataIsMultipart = true;
} else {
url =
this._baseUrlUpload +
'/files/{id}?uploadType=media&fields=headRevisionId'.replace('{id}', path);
data = new Blob([data], { type: 'application/octet-stream' });
}
this._xhr({
url,
method: isNew ? 'POST' : 'PATCH',
responseType: 'json',
data,
dataType,
dataIsMultipart,
success: response => {
this.logger.debug('Saved', path, this.logger.ts(ts));
const newRev = response.headRevisionId;
@ -239,20 +251,32 @@ class StorageGDrive extends StorageBase {
_getOAuthConfig() {
let clientId = this.appSettings.gdriveClientId;
let clientSecret;
if (!clientId) {
clientId =
location.origin.indexOf('localhost') >= 0
? GDriveClientId.Local
: GDriveClientId.Production;
if (Features.isDesktop) {
clientId = GDriveClientId.Desktop;
clientSecret = GDriveClientSecret.Desktop;
} else {
clientId =
location.origin.indexOf('localhost') >= 0
? GDriveClientId.Local
: GDriveClientId.Production;
}
}
return {
scope: 'https://www.googleapis.com/auth/drive',
url: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
clientId,
clientSecret,
width: 600,
height: 400
};
}
_useLocalOAuthRedirectListener() {
return Features.isDesktop;
}
}
export { StorageGDrive };

View File

@ -121,7 +121,7 @@ class StorageOneDrive extends StorageBase {
method: 'PUT',
responseType: 'json',
headers: rev ? { 'If-Match': rev } : null,
data: new Blob([data], { type: 'application/octet-stream' }),
data,
statuses: [200, 201, 412],
success: (response, xhr) => {
rev = response.eTag;
@ -213,7 +213,8 @@ class StorageOneDrive extends StorageBase {
method: 'POST',
responseType: 'json',
statuses: [200, 204],
data: new Blob([data], { type: 'application/json' }),
data,
dataType: 'application/json',
success: () => {
this.logger.debug('Made dir', path, this.logger.ts(ts));
return callback && callback();

View File

@ -3,6 +3,11 @@ import { Links } from 'const/links';
import { AppSettingsModel } from 'models/app-settings-model';
import { RuntimeDataModel } from 'models/runtime-data-model';
import { Logger } from 'util/logger';
import { StorageOAuthListener } from 'storage/storage-oauth-listener';
import { UrlFormat } from 'util/formatting/url-format';
import { Launcher } from 'comp/launcher';
import { omitEmpty } from 'util/fn';
import { Timeouts } from 'const/timeouts';
const MaxRequestRetries = 3;
@ -37,19 +42,30 @@ class StorageBase {
}
_xhr(config) {
const xhr = new XMLHttpRequest();
if (config.responseType) {
xhr.responseType = config.responseType;
}
const statuses = config.statuses || [200];
xhr.addEventListener('load', () => {
if (statuses.indexOf(xhr.status) >= 0) {
return config.success && config.success(xhr.response, xhr);
if (config.data) {
if (!config.dataType) {
config.dataType = 'application/octet-stream';
}
if (xhr.status === 401 && this._oauthToken) {
this._oauthRefreshToken(err => {
config.headers = {
...config.headers,
'Content-Type': config.dataType
};
}
if (this._oauthToken && !config.skipAuth) {
config.headers = {
...config.headers,
'Authorization': 'Bearer ' + this._oauthToken.accessToken
};
}
this._httpRequest(config, response => {
const statuses = config.statuses || [200];
if (statuses.indexOf(response.status) >= 0) {
return config.success && config.success(response.response, response);
}
if (response.status === 401 && this._oauthToken) {
this._oauthGetNewToken(err => {
if (err) {
return config.error && config.error('unauthorized', xhr);
return config.error && config.error('unauthorized', response);
} else {
config.tryNum = (config.tryNum || 0) + 1;
if (config.tryNum >= MaxRequestRetries) {
@ -57,16 +73,35 @@ class StorageBase {
'Too many authorize attempts, fail request',
config.url
);
return config.error && config.error('unauthorized', xhr);
return config.error && config.error('unauthorized', response);
}
this.logger.info('Repeat request, try #' + config.tryNum, config.url);
this._xhr(config);
}
});
} else {
return config.error && config.error('http status ' + xhr.status, xhr);
return config.error && config.error('http status ' + response.status, response);
}
});
}
_httpRequest(config, onLoad) {
const httpRequest = Launcher ? this._httpRequestLauncher : this._httpRequestWeb;
httpRequest(config, onLoad);
}
_httpRequestWeb(config, onLoad) {
const xhr = new XMLHttpRequest();
if (config.responseType) {
xhr.responseType = config.responseType;
}
xhr.addEventListener('load', () => {
onLoad({
status: xhr.status,
response: xhr.response,
getResponseHeader: name => xhr.getResponseHeader(name)
});
});
xhr.addEventListener('error', () => {
return config.error && config.error('network error', xhr);
});
@ -74,22 +109,68 @@ class StorageBase {
return config.error && config.error('timeout', xhr);
});
xhr.open(config.method || 'GET', config.url);
if (this._oauthToken && !config.skipAuth) {
xhr.setRequestHeader('Authorization', 'Bearer ' + this._oauthToken.accessToken);
}
if (config.headers) {
for (const [key, value] of Object.entries(config.headers)) {
xhr.setRequestHeader(key, value);
}
}
let data = config.data;
if (data instanceof ArrayBuffer) {
data = new Uint8Array(data);
if (data) {
if (!config.dataIsMultipart) {
data = [data];
}
data = new Blob(data, { type: config.dataType });
}
xhr.send(data);
}
_openPopup(url, title, width, height) {
_httpRequestLauncher(config, onLoad) {
const https = Launcher.req('https');
const req = https.request(config.url, {
method: config.method || 'GET',
headers: config.headers,
timeout: Timeouts.DefaultHttpRequest
});
req.on('response', res => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
let response = Buffer.concat(chunks);
if (config.responseType === 'json') {
response = JSON.parse(response.toString('utf8'));
} else {
response = response.buffer.slice(
response.byteOffset,
response.byteOffset + response.length
);
}
onLoad({
status: res.statusCode,
response,
getResponseHeader: name => res.headers[name.toLowerCase()]
});
});
});
req.on('error', () => {
return config.error && config.error('network error', {});
});
req.on('timeout', () => {
req.abort();
return config.error && config.error('timeout', {});
});
if (config.data) {
let data;
if (config.dataIsMultipart) {
data = Buffer.concat(config.data.map(chunk => Buffer.from(chunk)));
} else {
data = Buffer.from(config.data);
}
req.write(data);
}
req.end();
}
_openPopup(url, title, width, height, extras) {
const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left;
const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top;
@ -121,7 +202,7 @@ class StorageBase {
.map(key => key + '=' + settings[key])
.join(',');
return window.open(url, title, settings);
return window.open(url, title, settings, extras);
}
_getOauthRedirectUrl() {
@ -143,12 +224,37 @@ class StorageBase {
this._oauthToken = oldToken;
return callback();
}
const url =
opts.url +
'?client_id={cid}&scope={scope}&response_type=token&redirect_uri={url}'
.replace('{cid}', encodeURIComponent(opts.clientId))
.replace('{scope}', encodeURIComponent(opts.scope))
.replace('{url}', encodeURIComponent(this._getOauthRedirectUrl()));
if (oldToken && oldToken.refreshToken) {
return this._oauthExchangeRefreshToken(callback);
}
if (this._useLocalOAuthRedirectListener()) {
return StorageOAuthListener.listen()
.then(listener => {
const url = UrlFormat.makeUrl(opts.url, {
'client_id': opts.clientId,
'scope': opts.scope,
'state': listener.state,
'redirect_uri': listener.redirectUri,
'response_type': 'code',
'code_challenge': listener.codeChallenge,
'code_challenge_method': 'S256'
});
Launcher.openLink(url);
callback('browser-auth-started');
listener.callback = code => this._oauthCodeReceived(code, listener);
})
.catch(err => callback(err));
}
const url = UrlFormat.makeUrl(opts.url, {
'client_id': opts.clientId,
'scope': opts.scope,
'response_type': 'token',
'redirect_uri': this._getOauthRedirectUrl()
});
this.logger.debug('OAuth: popup opened');
const popupWindow = this._openPopup(url, 'OAuth', opts.width, opts.height);
if (!popupWindow) {
@ -203,21 +309,26 @@ class StorageBase {
return undefined;
}
}
return {
return omitEmpty({
dt: Date.now() - 60 * 1000,
tokenType: data.token_type,
accessToken: data.access_token,
refreshToken: data.refresh_token,
authenticationToken: data.authentication_token,
expiresIn: +data.expires_in,
scope: data.scope,
userId: data.user_id
};
});
}
_oauthRefreshToken(callback) {
_oauthGetNewToken(callback) {
this._oauthToken.expired = true;
this.runtimeData[this.name + 'OAuthToken'] = this._oauthToken;
this._oauthAuthorize(callback);
if (this._oauthToken.refreshToken) {
this._oauthExchangeRefreshToken(callback);
} else {
this._oauthAuthorize(callback);
}
}
_oauthRevokeToken(url) {
@ -243,6 +354,73 @@ class StorageBase {
}
return true;
}
_useLocalOAuthRedirectListener() {
return false;
}
_oauthCodeReceived(code, listener) {
this.logger.debug('OAuth code received');
Launcher.showMainWindow();
const config = this._getOAuthConfig();
this._xhr({
url: config.tokenUrl,
method: 'POST',
responseType: 'json',
skipAuth: true,
data: JSON.stringify({
'client_id': config.clientId,
'client_secret': config.clientSecret,
'grant_type': 'authorization_code',
code,
'code_verifier': listener.codeVerifier,
'redirect_uri': listener.redirectUri
}),
dataType: 'application/json',
success: response => {
this.logger.debug('OAuth code exchanged');
this._oauthProcessReturn(response);
},
error: err => {
this.logger.error('Error exchanging OAuth code', err);
}
});
}
_oauthExchangeRefreshToken(callback) {
this.logger.debug('Exchanging refresh token');
const { refreshToken } = this.runtimeData[this.name + 'OAuthToken'];
const config = this._getOAuthConfig();
this._xhr({
url: config.tokenUrl,
method: 'POST',
responseType: 'json',
skipAuth: true,
data: JSON.stringify({
'client_id': config.clientId,
'client_secret': config.clientSecret,
'grant_type': 'refresh_token',
'refresh_token': refreshToken
}),
dataType: 'application/json',
success: response => {
this.logger.debug('Refresh token exchanged');
this._oauthProcessReturn({
'refresh_token': refreshToken,
...response
});
callback();
},
error: (err, xhr) => {
if (xhr.status === 400) {
delete this.runtimeData[this.name + 'OAuthToken'];
this._oauthToken = null;
}
this.logger.error('Error exchanging refresh token', err);
callback && callback('Error exchanging refresh token');
}
});
}
}
export { StorageBase };

View File

@ -0,0 +1,90 @@
import { Logger } from 'util/logger';
import { Launcher } from 'comp/launcher';
import { noop } from 'util/fn';
const DefaultPort = 48149;
const logger = new Logger('storage-oauth-listener');
const StorageOAuthListener = {
server: null,
listen() {
return new Promise((resolve, reject) => {
if (this.server) {
this.stop();
}
const listener = {
callback: noop,
state: Math.round(Math.random() * Date.now()).toString()
};
const http = Launcher.req('http');
const server = http.createServer((req, resp) => {
resp.writeHead(204);
resp.end();
this.handleResult(req.url, listener);
});
const port = DefaultPort;
logger.info(`Starting OAuth listener on port ${port}...`);
server.listen(port);
server.on('error', err => {
logger.error('Failed to start OAuth listener', err);
reject('Failed to start OAuth listener: ' + err);
server.close();
});
server.on('listening', () => {
this.server = server;
listener.redirectUri = `http://127.0.0.1:${port}/oauth-result`;
this._setCodeVerifier(listener);
resolve(listener);
});
});
},
stop() {
if (this.server) {
this.server.close();
logger.info('OAuth listener stopped');
}
},
handleResult(url, listener) {
logger.info('OAuth result with code received');
this.stop();
url = new URL(url, 'http://localhost');
const state = url.searchParams.get('state');
if (!state) {
logger.info('OAuth result has no state');
return;
}
if (state !== listener.state) {
logger.info('OAuth result has bad state');
return;
}
const code = url.searchParams.get('code');
if (!code) {
logger.info('OAuth result has no code');
return;
}
listener.callback(code);
},
_setCodeVerifier(listener) {
const crypto = Launcher.req('crypto');
listener.codeVerifier = crypto.randomBytes(50).toString('hex');
const hash = crypto.createHash('sha256');
hash.update(listener.codeVerifier);
listener.codeChallenge = hash
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
};
export { StorageOAuthListener };

View File

@ -44,6 +44,18 @@ export function omit(obj, props) {
return result;
}
export function omitEmpty(obj) {
if (!obj) {
return obj;
}
return Object.entries(obj).reduce((result, [key, value]) => {
if (value) {
result[key] = value;
}
return result;
}, {});
}
export function mapObject(obj, fn) {
return Object.entries(obj).reduce((result, [key, value]) => {
result[key] = fn(value);

View File

@ -22,6 +22,13 @@ const UrlFormat = {
fileToDir(url) {
return url.replace(this.lastPartRegex, '') || '/';
},
makeUrl(base, args) {
const queryString = Object.entries(args)
.map(([key, value]) => key + '=' + encodeURIComponent(value))
.join('&');
return base + '?' + queryString;
}
};

View File

@ -735,6 +735,9 @@ class OpenView extends View {
this.busy = false;
if (err || !files) {
err = err ? err.toString() : '';
if (err === 'browser-auth-started') {
return;
}
if (err.lastIndexOf('OAuth', 0) !== 0 && !Alerts.alertDisplayed) {
Alerts.error({
header: Locale.openError,

View File

@ -1,6 +1,7 @@
Release notes
-------------
##### v1.13.0 (TBD)
`-` #1359: fixed Google Drive login issues in desktop apps
`+` #1341: auto-lock the app on screen lock on Windows
`+` #1065: PORTABLE_EXECUTABLE_DIR environment variable
`*` #1397: Segoe UI font on Windows