From 02643743111648faee3092cf7bb2bb4621fb9c45 Mon Sep 17 00:00:00 2001 From: Tamara Steinwender <tamara.steinwender@tugraz.at> Date: Thu, 4 Nov 2021 09:51:14 +0100 Subject: [PATCH] Add sessionsaving in session storage to nextcloud filepicker, add encrypt and decrypt functionality --- packages/auth/src/auth-keycloak.js | 1 - packages/file-handling/package.json | 1 + packages/file-handling/src/crypto.js | 74 +++++++ packages/file-handling/src/file-sink.js | 1 + packages/file-handling/src/file-source.js | 3 +- .../src/nextcloud-file-picker.js | 207 +++++++++++++++--- 6 files changed, 260 insertions(+), 27 deletions(-) create mode 100644 packages/file-handling/src/crypto.js diff --git a/packages/auth/src/auth-keycloak.js b/packages/auth/src/auth-keycloak.js index 6d625355..ddae039b 100644 --- a/packages/auth/src/auth-keycloak.js +++ b/packages/auth/src/auth-keycloak.js @@ -219,7 +219,6 @@ export class AuthKeycloak extends AdapterLitElement { throw Error("realm not set"); if (!this.clientId) throw Error("client-id not set"); - this._kcwrapper = new KeycloakWrapper(this.keycloakUrl, this.realm, this.clientId, this.silentCheckSsoRedirectUri, this.idpHint); this._kcwrapper.addEventListener('changed', this._onKCChanged); diff --git a/packages/file-handling/package.json b/packages/file-handling/package.json index affa5e0d..b9076885 100644 --- a/packages/file-handling/package.json +++ b/packages/file-handling/package.json @@ -37,6 +37,7 @@ "@open-wc/scoped-elements": "^1.3.3", "file-saver": "^2.0.2", "i18next": "^20.0.0", + "jose": "^3.16.1", "jszip": "^3.5.0", "lit-element": "^2.1.0", "lit-html": "^1.3.0", diff --git a/packages/file-handling/src/crypto.js b/packages/file-handling/src/crypto.js new file mode 100644 index 00000000..0e85c1b1 --- /dev/null +++ b/packages/file-handling/src/crypto.js @@ -0,0 +1,74 @@ +import { CompactEncrypt } from 'jose/jwe/compact/encrypt'; +import { parseJwk } from 'jose/jwk/parse'; +import {encode} from 'jose/util/base64url'; + +/** + * This "encrypts" the additional information string using the current oauth2 + * token, using A256GCM and PBES2-HS256+A128KW. + * + * Since we can't do any server side validation the user needs to confirm in the + * UI that he/she won't abuse the system. + * + * By using the token we make replaying an older requests harder and by using + * JOSE which needs crypto APIs, abusing the system can't reasonably be done by + * accident but only deliberately. + * + * This doesn't make things more secure, it just makes the intent of the user + * more clear in case the API isn't used through our UI flow. + * + * @param {string} token + * @param {string} payload + * @returns {string} + */ +export async function encrypt(token, payload) { + const encoder = new TextEncoder(); + const key = await parseJwk({kty: 'oct', k: encode(token)}, 'PBES2-HS256+A128KW'); + const jwe = await new CompactEncrypt(encoder.encode(payload)) + .setProtectedHeader({alg: 'PBES2-HS256+A128KW', enc: 'A256GCM'}) + .encrypt(key); + console.log("+++++++++++", jwe); + return jwe; +} + + +/** + * This "encrypts" the additional information string using the current oauth2 + * token, using A256GCM and PBES2-HS256+A128KW. + * + * Since we can't do any server side validation the user needs to confirm in the + * UI that he/she won't abuse the system. + * + * By using the token we make replaying an older requests harder and by using + * JOSE which needs crypto APIs, abusing the system can't reasonably be done by + * accident but only deliberately. + * + * This doesn't make things more secure, it just makes the intent of the user + * more clear in case the API isn't used through our UI flow. + * + * @param {string} token + * @param {string} payload + * @returns {string} + */ +export async function decrypt(token, payload) { + console.log("payload", payload); + const encoder = new TextEncoder(); + const key = await parseJwk({kty: 'oct', k: encode(token)}, 'PBES2-HS256+A128KW'); + const jwe = await new CompactEncrypt(encoder.encode(payload)) + .setProtectedHeader({alg: 'PBES2-HS256+A128KW', enc: 'A256GCM'}) + .decrypt(key); + console.log("jwe", jwe); + + return jwe; +} + +export function parseJwt (token) { + if (!token) + return null; + let base64Url = token.split('.')[1]; + let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + let jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); + + return JSON.parse(jsonPayload); +} diff --git a/packages/file-handling/src/file-sink.js b/packages/file-handling/src/file-sink.js index de0f4496..706aeba4 100644 --- a/packages/file-handling/src/file-sink.js +++ b/packages/file-handling/src/file-sink.js @@ -301,6 +301,7 @@ export class FileSink extends ScopedElementsMixin(DbpFileHandlingLitElement) { auth-info="${this.nextcloudAuthInfo}" directory-path="${this.nextcloudPath}" nextcloud-file-url="${this.nextcloudFileURL}" + store-nextcloud-session="true" @dbp-nextcloud-file-picker-file-uploaded="${(event) => { this.uploadToNextcloud(event.detail); }}" diff --git a/packages/file-handling/src/file-source.js b/packages/file-handling/src/file-source.js index a01ce36b..deb6bc3f 100644 --- a/packages/file-handling/src/file-source.js +++ b/packages/file-handling/src/file-source.js @@ -515,11 +515,12 @@ export class FileSource extends ScopedElementsMixin(DbpFileHandlingLitElement) { class="${classMap({hidden: this.nextcloudWebDavUrl === "" || this.nextcloudAuthUrl === ""})}" ?disabled="${this.disabled}" lang="${this.lang}" - subscribe="html-overrides" + subscribe="html-overrides,auth" auth-url="${this.nextcloudAuthUrl}" web-dav-url="${this.nextcloudWebDavUrl}" nextcloud-name="${this.nextcloudName}" nextcloud-file-url="${this.nextcloudFileURL}" + store-nextcloud-session="true" auth-info="${this.nextcloudAuthInfo}" allowed-mime-types="${this.allowedMimeTypes}" max-selected-items="${this.multipleFiles}" diff --git a/packages/file-handling/src/nextcloud-file-picker.js b/packages/file-handling/src/nextcloud-file-picker.js index bab4d37a..4ecd5dc6 100644 --- a/packages/file-handling/src/nextcloud-file-picker.js +++ b/packages/file-handling/src/nextcloud-file-picker.js @@ -12,6 +12,7 @@ import Tabulator from 'tabulator-tables'; import MicroModal from './micromodal.es'; import {name as pkgName} from './../package.json'; import * as fileHandlingStyles from './styles'; +import {encrypt, decrypt, parseJwt} from './crypto'; /** * NextcloudFilePicker web component @@ -21,7 +22,8 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { super(); this._i18n = createInstance(); this.lang = this._i18n.language; - + + this.auth = {}; this.authUrl = ''; this.webDavUrl = ''; this.nextcloudName = 'Nextcloud'; @@ -55,6 +57,11 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { this.abortUpload = false; this.authInfo = ''; this.selectBtnDisabled = true; + + this.storeSession = false; + this.showSubmenu = false; + this.bounCloseSubmenuHandler = this.closeSubmenu.bind(this); + this.initateOpensubmenu = false; } static get scopedElements() { @@ -71,6 +78,7 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { return { ...super.properties, lang: {type: String}, + auth: {type: Object}, authUrl: {type: String, attribute: 'auth-url'}, webDavUrl: {type: String, attribute: 'web-dav-url'}, nextcloudFileURL: {type: String, attribute: 'nextcloud-file-url'}, @@ -91,6 +99,8 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { activeDirectoryACL: {type: String, attribute: false}, abortUploadButton: {type: Boolean, attribute: false}, selectBtnDisabled: {type: Boolean, attribute: true}, + storeSession: {type: Boolean, attribute: 'store-nextcloud-session'}, + showSubmenu: {type: Boolean, attribute: false} }; } @@ -101,6 +111,9 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { case "lang": this._i18n.changeLanguage(this.lang); break; + case "auth": + this._updateAuth(); + break; case "directoriesOnly": if (this.directoriesOnly && this._("#select_all_wrapper")) { this._("#select_all_wrapper").classList.remove("button-container"); @@ -139,10 +152,13 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { super.connectedCallback(); const that = this; const i18n = this._i18n; + this._loginStatus = ''; + this._loginState = []; + this._loginCalled = false; + this.updateComplete.then(() => { // see: https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage window.addEventListener('message', this._onReceiveWindowMessage); - // see: http://tabulator.info/docs/4.7 this.tabulatorTable = new Tabulator(this._("#directory-content-table"), { layout: "fitColumns", @@ -356,21 +372,75 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { } /** + * Request a re-render every time isLoggedIn()/isLoading() changes + */ + _updateAuth() { + this._loginStatus = this.auth['login-status']; + + let newLoginState = [this.isLoggedIn(), this.isLoading()]; + + if (this._loginState.toString() !== newLoginState.toString()) { + this.requestUpdate(); + } + + this._loginState = newLoginState; + + if (this.isLoggedIn() && !this._loginCalled ) { + this._loginCalled = true; + this.loginCallback(); + } + } + + loginCallback() { + this.checkSessionStorage() + } + + /** + * Returns if a person is set in or not * + * @returns {boolean} true or false */ - async checkSessionStorage() { - // Comment in for remember me functionality - /*if (sessionStorage.getItem("nextcloud-webdav-username") && sessionStorage.getItem("nextcloud-webdav-password")) { - this.webDavClient = createClient( - this.webDavUrl + "/" + sessionStorage.getItem("nextcloud-webdav-username"), - { - username: sessionStorage.getItem("nextcloud-webdav-username"), - password: sessionStorage.getItem("nextcloud-webdav-password") - } - ); - this.loadDirectory(this.directoryPath); + isLoggedIn() { + return (this.auth.person !== undefined && this.auth.person !== null); + } - }*/ + /** + * Returns true if a person has successfully logged in + * + * @returns {boolean} true or false + */ + isLoading() { + if (this._loginStatus === "logged-out") + return false; + + return (!this.isLoggedIn() && this.auth.token !== undefined); + } + + + + /** + * + */ + async checkSessionStorage() { + const publicId = this.auth['person-id']; + const token = parseJwt(this.auth.token); + const sessionId = token ? token.sid : ""; + if (this.isLoggedIn() && this.storeSession && sessionId + && sessionStorage.getItem("nextcloud-webdav-username" + publicId) + && sessionStorage.getItem("nextcloud-webdav-password" + publicId) ){ + console.log("----------", sessionStorage.getItem("nextcloud-webdav-username" + publicId)); + const sessionStorageName = await sessionStorage.getItem("nextcloud-webdav-username" + publicId); + console.log("decrypt:", await decrypt(sessionId, sessionStorageName)); + /* this.webDavClient = createClient( + this.webDavUrl + "/" + sessionStorage.getItem("nextcloud-webdav-username"), + { + username: decrypt(sessionId, sessionStorage.getItem("nextcloud-webdav-username" + publicId)), + password: decrypt(sessionId, sessionStorage.getItem("nextcloud-webdav-password" + publicId)) + } + ); + this.isPickerActive = true; + this.loadDirectory(this.directoryPath);*/ + } } /** @@ -397,7 +467,7 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { return !deny; } - openFilePicker() { + async openFilePicker() { const i18n = this._i18n; if (this.webDavClient === null) { this.loading = true; @@ -414,7 +484,16 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { return this.shadowRoot === null ? this.querySelectorAll(selector) : this.shadowRoot.querySelectorAll(selector); } - onReceiveWindowMessage(event) { + async persistStorageMaybe() { + if (navigator.storage && navigator.storage.persist) { + if (await navigator.storage.persist()) + console.log("Storage will not be cleared except by explicit user action"); + else + console.log("Storage may be cleared by the UA under storage pressure."); + } + } + + async onReceiveWindowMessage(event) { if (this.webDavClient === null) { const data = event.data; @@ -432,12 +511,21 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { } ); - /* Comment this in for remember me functionality - if (this._("#remember-checkbox") && this._("#remember-checkbox").checked) { - sessionStorage.setItem('nextcloud-webdav-username', data.loginName); - sessionStorage.setItem('nextcloud-webdav-password', data.token); + + if (this.storeSession && this.isLoggedIn() && this._("#remember-checkbox") && this._("#remember-checkbox").checked) { + this.persistStorageMaybe(); + const publicId = this.auth['person-id']; + const token = parseJwt(this.auth.token); + const sessionId = token ? token.sid : ""; + if (sessionId) { + const encrytedName = await encrypt(sessionId, data.loginName); + const encrytedToken = await encrypt(sessionId, data.token); + sessionStorage.setItem('nextcloud-webdav-username' + publicId, encrytedName); + sessionStorage.setItem('nextcloud-webdav-password' + publicId, encrytedToken); + + } } - */ + this.loadDirectory(this.directoryPath); } } @@ -1115,6 +1203,34 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { return false; } + closeSubmenu() { + if (this.initateOpensubmenu && this.showSubmenu) { + this.initateOpensubmenu = false; + return; + } + if (this.showSubmenu){ + document.removeEventListener('click', this.bounCloseSubmenuHandler); + this.showSubmenu = false; + } + } + + toggleSubmenu() { + if (!this.showSubmenu) { + this.initateOpensubmenu = true; + this.showSubmenu = true; + document.addEventListener('click', this.bounCloseSubmenuHandler); + } + } + + logOut() { + this.webDavClient = null; + this.isPickerActive = false; + sessionStorage.removeItem('nextcloud-webdav-username'); + sessionStorage.removeItem('nextcloud-webdav-password'); + + console.log("log out!"); + } + /** * Returns the parent directory path * @@ -1413,6 +1529,31 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { #abortButton:hover { color: white; } + + #submenu { + height: 33px; + width: 33px; + justify-content: center; + display: flex; + align-items: center; + cursor: pointer; + } + + .submenu-icon { + margin-top: -5px; + } + + #submenu-content { + position: absolute; + right: 0px; + top: 33px; + z-index: 1; + } + + .menu-buttons { + display: flex; + gap: 1em; + } @keyframes added { 0% { @@ -1649,7 +1790,6 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { render() { const i18n = this._i18n; const tabulatorCss = commonUtils.getAssetURL(pkgName, 'tabulator-tables/css/tabulator.min.css'); - return html` <div class="wrapper"> <link rel="stylesheet" href="${tabulatorCss}"> @@ -1675,7 +1815,7 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { }}">${i18n.t('nextcloud-file-picker.connect-nextcloud', {name: this.nextcloudName})} </button> </div> - <div class="block text-center m-inherit ${classMap({hidden: this.isPickerActive})} hidden"> <!-- remove hidden to enable remember me --> + <div class="block text-center m-inherit ${classMap({hidden: this.isPickerActive && !this.storeSession})}"> <!-- remove hidden to enable remember me --> <label class="button-container remember-container"> ${i18n.t('nextcloud-file-picker.remember-me')} <input type="checkbox" id="remember-checkbox" name="remember"> @@ -1693,7 +1833,8 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { <div class="nextcloud-content ${classMap({hidden: !this.isPickerActive})}"> <div class="nextcloud-nav"> <p>${this.getBreadcrumb()}</p> - <div class="add-folder ${classMap({hidden: !this.directoriesOnly})}"> + <div class="menu-buttons"> + <div class="add-folder ${classMap({hidden: !this.directoriesOnly})}"> <div class="inline-block"> <div id="new-folder-wrapper" class="hidden"> <input type="text" @@ -1716,7 +1857,23 @@ export class NextcloudFilePicker extends ScopedElementsMixin(DBPLitElement) { <dbp-icon name="plus" class="nextcloud-add-folder" id="add-folder-button"></dbp-icon> </button> </div> - + <div id="submenu" class="${classMap({hidden: !this.storeSession})}" + title="${i18n.t('nextcloud-file-picker.open-submenu')}" + @click="${() => { + this.toggleSubmenu(); + }}"> + <dbp-icon name="menu-dots" class="submenu-icon"></dbp-icon> + <div id="submenu-content" class="${classMap({hidden: !this.showSubmenu})}"> + <button class="button" + title="${i18n.t('nextcloud-file-picker.log-out')}" + @click="${() => { + this.logOut(); + }}"> + Abmelden + </button> + </div> + </div> + </div> </div> <div class="table-wrapper"> <table id="directory-content-table" class="force-no-select"></table> -- GitLab