From 1effcdd7f7ba30b69343e41a391bde9e87903b49 Mon Sep 17 00:00:00 2001 From: fyears Date: Sun, 28 Nov 2021 16:28:52 +0800 Subject: [PATCH] basic dropbox auth flow --- src/main.ts | 205 ++++++++++++++++++++++++++++++++++++---- src/remote.ts | 16 ++++ src/remoteForDropbox.ts | 95 +++++++++++++++++++ styles.css | 8 ++ 4 files changed, 306 insertions(+), 18 deletions(-) diff --git a/src/main.ts b/src/main.ts index cc532bd..4fd9694 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,7 +28,14 @@ import { isPasswordOk, getSyncPlan, doActualSync } from "./sync"; import { S3Config, DEFAULT_S3_CONFIG } from "./s3"; import { WebdavConfig, DEFAULT_WEBDAV_CONFIG, WebdavAuthType } from "./webdav"; -import { DropboxConfig, DEFAULT_DROPBOX_CONFIG } from "./remoteForDropbox"; +import { + DropboxConfig, + DEFAULT_DROPBOX_CONFIG, + getCodeVerifierAndChallenge, + getAuthUrl, + sendAuthReq, + setConfigBySuccessfullAuthInplace, +} from "./remoteForDropbox"; import { RemoteClient } from "./remote"; import { exportSyncPlansToFiles } from "./debugMode"; @@ -185,7 +192,11 @@ export default class RemotelySavePlugin extends Plugin { } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this.settings = Object.assign( + {}, + JSON.parse(JSON.stringify(DEFAULT_SETTINGS)) /* copy an object */, + await this.loadData() + ); } async saveSettings() { @@ -259,6 +270,108 @@ export class PasswordModal extends Modal { } } +export class DropboxAuthModal extends Modal { + readonly plugin: RemotelySavePlugin; + readonly authDiv: HTMLDivElement; + readonly revokeAuthDiv: HTMLDivElement; + readonly revokeAuthSetting: Setting; + constructor( + app: App, + plugin: RemotelySavePlugin, + authDiv: HTMLDivElement, + revokeAuthDiv: HTMLDivElement, + revokeAuthSetting: Setting + ) { + super(app); + this.plugin = plugin; + this.authDiv = authDiv; + this.revokeAuthDiv = revokeAuthDiv; + this.revokeAuthSetting = revokeAuthSetting; + } + + onOpen() { + let { contentEl } = this; + + const k = getCodeVerifierAndChallenge(); + const authUrl = getAuthUrl( + this.plugin.settings.dropbox.clientID, + k.challenge + ); + + contentEl.createEl("p", { + text: "Step 1: Visit the following address in a browser, and follow the steps on the web page to authorize.", + }); + contentEl.createEl("p").createEl("a", { + href: authUrl, + text: authUrl, + }); + + contentEl.createEl("p", { + text: 'Step 2: In the end of the web flow, you obtain a long code. Paste it here then click "Submit".', + }); + + let authCode = ""; + new Setting(contentEl) + .setName("Auth Code from web page") + .setDesc('You need to click "Confirm".') + .addText((text) => + text + .setPlaceholder("") + .setValue("") + .onChange((val) => { + authCode = val.trim(); + }) + ) + .addButton(async (button) => { + button.setButtonText("Confirm"); + button.onClick(async () => { + new Notice("Trying to connect to Dropbox"); + try { + const authRes = await sendAuthReq( + this.plugin.settings.dropbox.clientID, + k.verifier, + authCode + ); + setConfigBySuccessfullAuthInplace( + this.plugin.settings.dropbox, + authRes + ); + const client = new RemoteClient( + "dropbox", + undefined, + undefined, + this.plugin.settings.dropbox + ); + const username = await client.getUser(); + this.plugin.settings.dropbox.username = username; + await this.plugin.saveSettings(); + new Notice(`Good! We've connected to Dropbox as user ${username}!`); + this.authDiv.toggleClass( + "dropbox-auth-button-hide", + this.plugin.settings.dropbox.username !== "" + ); + this.revokeAuthDiv.toggleClass( + "dropbox-revoke-auth-button-hide", + this.plugin.settings.dropbox.username === "" + ); + this.revokeAuthSetting.setDesc( + `You've connected as user ${this.plugin.settings.dropbox.username}. If you want to disconnect, click this button` + ); + this.close(); + } catch (err) { + console.log(err); + new Notice("Something goes wrong while connecting to Dropbox."); + } + }); + }); + } + + onClose() { + let { contentEl } = this; + contentEl.empty(); + } +} + class RemotelySaveSettingTab extends PluginSettingTab { plugin: RemotelySavePlugin; @@ -299,11 +412,11 @@ class RemotelySaveSettingTab extends PluginSettingTab { }); }); - // we need to create the div in advance of s3Div and webdavDiv + // we need to create the div in advance of any other service divs const serviceChooserDiv = generalDiv.createEl("div"); let clickChooserTimes = 0; - serviceChooserDiv.onClickEvent((x) => { + serviceChooserDiv.onClickEvent(async (x) => { if (Platform.isIosApp) { // downgrade the experiment // because iOS doesn't support x.detail @@ -320,13 +433,13 @@ class RemotelySaveSettingTab extends PluginSettingTab { ); } else if (!this.plugin.settings.enableExperimentService) { this.plugin.settings.enableExperimentService = true; - this.plugin.saveSettings(); + await this.plugin.saveSettings(); new Notice( "You've enabled hidden unstable experimental webdav support. Reopen settings again and try webdav with caution." ); } else if (this.plugin.settings.enableExperimentService) { this.plugin.settings.enableExperimentService = false; - this.plugin.saveSettings(); + await this.plugin.saveSettings(); new Notice( "You've disabled hidden unstable experimental webdav support. Reopen settings again." ); @@ -474,21 +587,77 @@ class RemotelySaveSettingTab extends PluginSettingTab { cls: "dropbox-disclaimer", }); dropboxDiv.createEl("p", { - text: "We create a folder App/obsidian-remotely-save on your Dropbox. All files/folders sync would happen inside this folder.", + text: "We will create a folder App/obsidian-remotely-save on your Dropbox. All files/folders sync would happen inside this folder.", }); - new Setting(dropboxDiv) - .setName("access token") - .setDesc("access token") - .addText((text) => - text - .setPlaceholder("") - .setValue(this.plugin.settings.dropbox.accessToken) - .onChange(async (value) => { - this.plugin.settings.dropbox.accessToken = value.trim(); + const dropboxSelectAuthDiv = dropboxDiv.createDiv(); + const dropboxAuthDiv = dropboxSelectAuthDiv.createDiv({ + cls: "dropbox-auth-button-hide", + }); + const dropboxRevokeAuthDiv = dropboxSelectAuthDiv.createDiv({ + cls: "dropbox-revoke-auth-button-hide", + }); + + const revokeAuthSetting = new Setting(dropboxRevokeAuthDiv) + .setName("Revoke Auth") + .setDesc( + `You've connected as user ${this.plugin.settings.dropbox.username}. If you want to disconnect, click this button` + ) + .addButton(async (button) => { + button.setButtonText("Revoke Auth"); + button.onClick(async () => { + try { + console.log("settings "); + console.log(this.plugin.settings.dropbox); + const client = new RemoteClient( + "dropbox", + undefined, + undefined, + this.plugin.settings.dropbox + ); + await client.revokeAuth(); + this.plugin.settings.dropbox = { ...DEFAULT_DROPBOX_CONFIG }; await this.plugin.saveSettings(); - }) - ); + dropboxAuthDiv.toggleClass( + "dropbox-auth-button-hide", + this.plugin.settings.dropbox.username !== "" + ); + dropboxRevokeAuthDiv.toggleClass( + "dropbox-revoke-auth-button-hide", + this.plugin.settings.dropbox.username === "" + ); + new Notice("Revoked!"); + } catch (err) { + console.log(err); + new Notice("Something goes wrong while revoking"); + } + }); + }); + + new Setting(dropboxAuthDiv) + .setName("Auth") + .setDesc("Auth") + .addButton(async (button) => { + button.setButtonText("Auth"); + button.onClick(async () => { + new DropboxAuthModal( + this.app, + this.plugin, + dropboxAuthDiv, + dropboxRevokeAuthDiv, + revokeAuthSetting + ).open(); + }); + }); + + dropboxAuthDiv.toggleClass( + "dropbox-auth-button-hide", + this.plugin.settings.dropbox.username !== "" + ); + dropboxRevokeAuthDiv.toggleClass( + "dropbox-revoke-auth-button-hide", + this.plugin.settings.dropbox.username === "" + ); new Setting(dropboxDiv) .setName("check connectivity") diff --git a/src/remote.ts b/src/remote.ts index 617e535..33a8d16 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -188,4 +188,20 @@ export class RemoteClient { throw Error(`not supported service type ${this.serviceType}`); } }; + + getUser = async () => { + if (this.serviceType === "dropbox") { + return await dropbox.getUserDisplayName(this.dropboxClient); + } else { + throw Error(`not supported service type ${this.serviceType}`); + } + }; + + revokeAuth = async () => { + if (this.serviceType === "dropbox") { + return await dropbox.revokeAuth(this.dropboxClient); + } else { + throw Error(`not supported service type ${this.serviceType}`); + } + }; } diff --git a/src/remoteForDropbox.ts b/src/remoteForDropbox.ts index bd0868c..a58e466 100644 --- a/src/remoteForDropbox.ts +++ b/src/remoteForDropbox.ts @@ -1,5 +1,7 @@ import * as path from "path"; import { FileStats, Vault } from "obsidian"; +import { Buffer } from "buffer"; +import * as crypto from "crypto"; import { Dropbox, DropboxResponse, files } from "dropbox"; export { Dropbox } from "dropbox"; @@ -17,10 +19,22 @@ import { strict as assert } from "assert"; export interface DropboxConfig { accessToken: string; + clientID: string; + refreshToken: string; + accessTokenExpiresInSeconds: number; + accessTokenExpiresAtTime: number; + accountID: string; + username: string; } export const DEFAULT_DROPBOX_CONFIG = { accessToken: "", + clientID: "", + refreshToken: "", + accessTokenExpiresInSeconds: 0, + accessTokenExpiresAtTime: 0, + accountID: "", + username: "", }; export const getDropboxPath = (fileOrFolderPath: string) => { @@ -370,3 +384,84 @@ export const checkConnectivity = async (dbx: Dropbox) => { return false; } }; + +export const getUserDisplayName = async (dbx: Dropbox) => { + const acct = await dbx.usersGetCurrentAccount(); + return acct.result.name.display_name; +}; + +/** + * Dropbox authorization using PKCE + * see https://dropbox.tech/developers/pkce--what-and-why- + * + */ + +const specialBase64Encode = (str: Buffer) => { + return str + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +}; +const sha256 = (buffer: string) => { + return crypto.createHash("sha256").update(buffer).digest(); +}; + +export const getCodeVerifierAndChallenge = () => { + const codeVerifier = specialBase64Encode(crypto.randomBytes(32)); + // console.log(`Client generated code_verifier: ${codeVerifier}`); + const codeChallenge = specialBase64Encode(sha256(codeVerifier)); + // console.log(`Client generated code_challenge: ${codeChallenge}`); + return { + verifier: codeVerifier, + challenge: codeChallenge, + }; +}; + +export const getAuthUrl = (appKey: string, challenge: string) => { + return `https://www.dropbox.com/oauth2/authorize?client_id=${appKey}&response_type=code&code_challenge=${challenge}&code_challenge_method=S256&token_access_type=offline`; +}; + +export interface DropboxSuccessAuthRes { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; + uid: string; + account_id: string; +} + +export const sendAuthReq = async ( + appKey: string, + verifier: string, + authCode: string +) => { + const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", { + method: "POST", + body: new URLSearchParams({ + code: authCode, + grant_type: "authorization_code", + code_verifier: verifier, + client_id: appKey, + }), + }); + const resp2 = (await resp1.json()) as DropboxSuccessAuthRes; + return resp2; +}; + +export const setConfigBySuccessfullAuthInplace = ( + config: DropboxConfig, + authRes: DropboxSuccessAuthRes +) => { + config.accessToken = authRes.access_token; + config.refreshToken = authRes.refresh_token; + config.accessTokenExpiresInSeconds = authRes.expires_in; + config.accessTokenExpiresAtTime = + Date.now() + authRes.expires_in * 1000 - 10 * 1000; + config.accountID = authRes.account_id; +}; + +export const revokeAuth = async (client: Dropbox) => { + await client.authTokenRevoke(); +}; diff --git a/styles.css b/styles.css index 5fcb4d8..b73801e 100644 --- a/styles.css +++ b/styles.css @@ -18,6 +18,14 @@ display: none; } +.dropbox-auth-button-hide { + display: none; +} + +.dropbox-revoke-auth-button-hide { + display: none; +} + .webdav-disclaimer { font-weight: bold; }