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