From 00d9d8366ea17f4ae7882b5596bfddccbb1bd312 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 11 Nov 2024 18:14:45 +0100 Subject: [PATCH 1/2] fix: Redirect user to login if session is terminated If a session timed out or was closed in another tab, then currently the user gets random error messages. This intercepts 401 responses (should only happen if logged out, or the users does something wrong). If we get a 401, we make sure its because of the session, by checking if the user can access the files app. If that is also the case we forward the user to the login page and set the redirect URL to the last used URL. Signed-off-by: Ferdinand Thiessen --- core/src/utils/xhr-request.js | 59 +++++++++++++++++++++--- cypress/e2e/login/login-redirect.cy.ts | 62 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 cypress/e2e/login/login-redirect.cy.ts diff --git a/core/src/utils/xhr-request.js b/core/src/utils/xhr-request.js index ff8b7641b07d8..75f99e3f671b7 100644 --- a/core/src/utils/xhr-request.js +++ b/core/src/utils/xhr-request.js @@ -19,7 +19,8 @@ * along with this program. If not, see . */ -import { getRootUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { generateUrl, getRootUrl } from '@nextcloud/router' /** * @@ -42,6 +43,41 @@ const isNextcloudUrl = (url) => { || (isRelativeUrl(url) && url.startsWith(getRootUrl())) } +/** + * Check if a user was logged in but is now logged-out. + * If this is the case then the user will be forwarded to the login page. + * @returns {Promise} + */ +async function checkLoginStatus() { + // skip if no logged in user + if (getCurrentUser() === null) { + return + } + + // skip if already running + if (checkLoginStatus.running === true) { + return + } + + // only run one request in parallel + checkLoginStatus.running = true + + try { + // We need to check this as a 401 in the first place could also come from other reasons + const { status } = await window.fetch(generateUrl('/apps/files')) + if (status === 401) { + console.warn('User session was terminated, forwarding to login page.') + window.location = generateUrl('/login?redirect_url={url}', { + url: window.location.pathname + window.location.search + window.location.hash, + }) + } + } catch (error) { + console.warn('Could not check login-state') + } finally { + delete checkLoginStatus.running + } +} + /** * Intercept XMLHttpRequest and fetch API calls to add X-Requested-With header * @@ -51,17 +87,24 @@ export const interceptRequests = () => { XMLHttpRequest.prototype.open = (function(open) { return function(method, url, async) { open.apply(this, arguments) - if (isNextcloudUrl(url) && !this.getResponseHeader('X-Requested-With')) { - this.setRequestHeader('X-Requested-With', 'XMLHttpRequest') + if (isNextcloudUrl(url)) { + if (!this.getResponseHeader('X-Requested-With')) { + this.setRequestHeader('X-Requested-With', 'XMLHttpRequest') + } + this.addEventListener('loadend', function() { + if (this.status === 401) { + checkLoginStatus() + } + }) } } })(XMLHttpRequest.prototype.open) window.fetch = (function(fetch) { - return (resource, options) => { + return async (resource, options) => { // fetch allows the `input` to be either a Request object or any stringifyable value if (!isNextcloudUrl(resource.url ?? resource.toString())) { - return fetch(resource, options) + return await fetch(resource, options) } if (!options) { options = {} @@ -76,7 +119,11 @@ export const interceptRequests = () => { options.headers['X-Requested-With'] = 'XMLHttpRequest' } - return fetch(resource, options) + const response = await fetch(resource, options) + if (response.status === 401) { + checkLoginStatus() + } + return response } })(window.fetch) } diff --git a/cypress/e2e/login/login-redirect.cy.ts b/cypress/e2e/login/login-redirect.cy.ts new file mode 100644 index 0000000000000..eb0710dcbccf5 --- /dev/null +++ b/cypress/e2e/login/login-redirect.cy.ts @@ -0,0 +1,62 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Test that when a session expires / the user logged out in another tab, + * the user gets redirected to the login on the next request. + */ +describe('Logout redirect ', { testIsolation: true }, () => { + + let user + + before(() => { + cy.createRandomUser() + .then(($user) => { + user = $user + }) + }) + + it('Redirects to login if session timed out', () => { + // Login and see settings + cy.login(user) + cy.visit('/settings/user#profile') + cy.findByRole('checkbox', { name: /Enable profile/i }) + .should('exist') + + // clear session + cy.clearAllCookies() + + // trigger an request + cy.findByRole('checkbox', { name: /Enable profile/i }) + .click({ force: true }) + + // See that we are redirected + cy.url() + .should('match', /\/login/i) + .and('include', `?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`) + + cy.get('form[name="login"]').should('be.visible') + }) + + it('Redirect from login works', () => { + cy.logout() + // visit the login + cy.visit(`/login?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`) + + // see login + cy.get('form[name="login"]').should('be.visible') + cy.get('form[name="login"]').within(() => { + cy.get('input[name="user"]').type(user.userId) + cy.get('input[name="password"]').type(user.password) + cy.contains('button[data-login-form-submit]', 'Log in').click() + }) + + // see that we are correctly redirected + cy.url().should('include', '/index.php/settings/user#profile') + cy.findByRole('checkbox', { name: /Enable profile/i }) + .should('exist') + }) + +}) From fbbbd2a292cb117fd965dad9a45ebbc42a7803ee Mon Sep 17 00:00:00 2001 From: nextcloud-command Date: Thu, 9 Jan 2025 08:45:57 +0000 Subject: [PATCH 2/2] chore(assets): Recompile assets Signed-off-by: nextcloud-command --- dist/core-main.js | 4 ++-- dist/core-main.js.LICENSE.txt | 21 +++++++++++++++++++++ dist/core-main.js.map | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/dist/core-main.js b/dist/core-main.js index 114a689891892..41b563966c9e7 100644 --- a/dist/core-main.js +++ b/dist/core-main.js @@ -1,3 +1,3 @@ /*! For license information please see core-main.js.LICENSE.txt */ -(()=>{var e,i,o,r={17732:(e,i,o)=>{"use strict";var r={};o.r(r),o.d(r,{deleteKey:()=>k,getApps:()=>v,getKeys:()=>x,getValue:()=>w,setValue:()=>y});var s={};o.r(s),o.d(s,{formatLinksPlain:()=>gn,formatLinksRich:()=>fn,plainToRich:()=>pn,richToPlain:()=>An});var a={};o.r(a),o.d(a,{dismiss:()=>bn,query:()=>Cn}),o(84315),o(7452);var c=o(61338),l=o(4523),u=o(74692),h=o.n(u),d=o(85168),p=o(96763);const A={updatableNotification:null,getDefaultNotificationFunction:null,setDefault(t){this.getDefaultNotificationFunction=t},hide(t,e){l.default.isFunction(t)&&(e=t,t=void 0),t?(t.each((function(){h()(this)[0].toastify?h()(this)[0].toastify.hideToast():p.error("cannot hide toast because object is not set"),this===this.updatableNotification&&(this.updatableNotification=null)})),e&&e.call(),this.getDefaultNotificationFunction&&this.getDefaultNotificationFunction()):p.error("Missing argument $row in OC.Notification.hide() call, caller needs to be adjusted to only dismiss its own notification")},showHtml(t,e){(e=e||{}).isHTML=!0,e.timeout=e.timeout?e.timeout:d.DH;const n=(0,d.rG)(t,e);return n.toastElement.toastify=n,h()(n.toastElement)},show(t,e){(e=e||{}).timeout=e.timeout?e.timeout:d.DH;const n=(0,d.rG)(function(t){return t.toString().split("&").join("&").split("<").join("<").split(">").join(">").split('"').join(""").split("'").join("'")}(t),e);return n.toastElement.toastify=n,h()(n.toastElement)},showUpdate(t){return this.updatableNotification&&this.updatableNotification.hideToast(),this.updatableNotification=(0,d.rG)(t,{timeout:d.DH}),this.updatableNotification.toastElement.toastify=this.updatableNotification,h()(this.updatableNotification.toastElement)},showTemporary(t,e){(e=e||{}).timeout=e.timeout||d.Jt;const n=(0,d.rG)(t,e);return n.toastElement.toastify=n,h()(n.toastElement)},isHidden:()=>!h()("#content").find(".toastify").length},f=l.default.throttle((()=>{A.showTemporary(t("core","Connection to server lost"))}),7e3,{trailing:!1});let g=!1;const m={enableDynamicSlideToggle(){g=!0},showAppSidebar:function(t){(t||h()("#app-sidebar")).removeClass("disappear").show(),h()("#app-content").trigger(new(h().Event)("appresized"))},hideAppSidebar:function(t){(t||h()("#app-sidebar")).hide().addClass("disappear"),h()("#app-content").trigger(new(h().Event)("appresized"))}};var C=o(63814);function b(t,e,n){"post"!==t&&"delete"!==t||!_t.PasswordConfirmation.requiresPasswordConfirmation()?(n=n||{},h().ajax({type:t.toUpperCase(),url:(0,C.KT)("apps/provisioning_api/api/v1/config/apps")+e,data:n.data||{},success:n.success,error:n.error})):_t.PasswordConfirmation.requirePasswordConfirmation(_.bind(b,this,t,e,n))}function v(t){b("get","",t)}function x(t,e){b("get","/"+t,e)}function w(t,e,n,i){(i=i||{}).data={defaultValue:n},b("get","/"+t+"/"+e,i)}function y(t,e,n,i){(i=i||{}).data={value:n},b("post","/"+t+"/"+e,i)}function k(t,e,n){b("delete","/"+t+"/"+e,n)}const B=window.oc_appconfig||{},E={getValue:function(t,e,n,i){w(t,e,n,{success:i})},setValue:function(t,e,n){y(t,e,n)},getApps:function(t){v({success:t})},getKeys:function(t,e){x(t,{success:e})},deleteKey:function(t,e){k(t,e)}},I=void 0!==window._oc_appswebroots&&window._oc_appswebroots;var D=o(21391),S=o.n(D),T=o(78112),M=o(96763);const P={create:"POST",update:"PROPPATCH",patch:"PROPPATCH",delete:"DELETE",read:"PROPFIND"};function O(t,e){if(l.default.isArray(t))return l.default.map(t,(function(t){return O(t,e)}));var n={href:t.href};return l.default.each(t.propStat,(function(t){if("HTTP/1.1 200 OK"===t.status)for(var i in t.properties){var o=i;i in e&&(o=e[i]),n[o]=t.properties[i]}})),n.id||(n.id=H(n.href)),n}function H(t){var e=t.indexOf("?");e>0&&(t=t.substr(0,e));var n,i=t.split("/");do{n=i[i.length-1],i.pop()}while(!n&&i.length>0);return n}function z(t){return t>=200&&t<=299}function R(t,e,n,i){return t.propPatch(e.url,function(t,e){var n,i={};for(n in t){var o=e[n],r=t[n];o||(M.warn('No matching DAV property for property "'+n),o=n),(l.default.isBoolean(r)||l.default.isNumber(r))&&(r=""+r),i[o]=r}return i}(n.changed,e.davProperties),i).then((function(t){z(t.status)?l.default.isFunction(e.success)&&e.success(n.toJSON()):l.default.isFunction(e.error)&&e.error(t)}))}const N=S().noConflict();Object.assign(N,{davCall:(t,e)=>{var n=new T.dav.Client({baseUrl:t.url,xmlNamespaces:l.default.extend({"DAV:":"d","http://owncloud.org/ns":"oc"},t.xmlNamespaces||{})});n.resolveUrl=function(){return t.url};var i=l.default.extend({"X-Requested-With":"XMLHttpRequest",requesttoken:OC.requestToken},t.headers);return"PROPFIND"===t.type?function(t,e,n,i){return t.propFind(e.url,l.default.values(e.davProperties)||[],e.depth,i).then((function(t){if(z(t.status)){if(l.default.isFunction(e.success)){var n=l.default.invert(e.davProperties),i=O(t.body,n);e.depth>0&&i.shift(),e.success(i)}}else l.default.isFunction(e.error)&&e.error(t)}))}(n,t,0,i):"PROPPATCH"===t.type?R(n,t,e,i):"MKCOL"===t.type?function(t,e,n,i){return t.request(e.type,e.url,i,null).then((function(o){z(o.status)?R(t,e,n,i):l.default.isFunction(e.error)&&e.error(o)}))}(n,t,e,i):function(t,e,n,i){return i["Content-Type"]="application/json",t.request(e.type,e.url,i,e.data).then((function(t){if(z(t.status)){if(l.default.isFunction(e.success)){if("PUT"===e.type||"POST"===e.type||"MKCOL"===e.type){var i=t.body||n.toJSON(),o=t.xhr.getResponseHeader("Content-Location");return"POST"===e.type&&o&&(i.id=H(o)),void e.success(i)}if(207===t.status){var r=l.default.invert(e.davProperties);e.success(O(t.body,r))}else e.success(t.body)}}else l.default.isFunction(e.error)&&e.error(t)}))}(n,t,e,i)},davSync:(t=>(e,n,i)=>{var o={type:P[e]||e},r=n instanceof t.Collection;if("update"===e&&(n.hasInnerCollection?o.type="MKCOL":(n.usePUT||n.collection&&n.collection.usePUT)&&(o.type="PUT")),i.url||(o.url=l.default.result(n,"url")||function(){throw new Error('A "url" property or function must be specified')}()),null!=i.data||!n||"create"!==e&&"update"!==e&&"patch"!==e||(o.data=JSON.stringify(i.attrs||n.toJSON(i))),"PROPFIND"!==o.type&&(o.processData=!1),"PROPFIND"===o.type||"PROPPATCH"===o.type){var s=n.davProperties;!s&&n.model&&(s=n.model.prototype.davProperties),s&&(l.default.isFunction(s)?o.davProperties=s.call(n):o.davProperties=s),o.davProperties=l.default.extend(o.davProperties||{},i.davProperties),l.default.isUndefined(i.depth)&&(i.depth=r?1:0)}var a=i.error;i.error=function(t,e,n){i.textStatus=e,i.errorThrown=n,a&&a.call(i.context,t,e,n)};var c=i.xhr=t.davCall(l.default.extend(o,i),n);return n.trigger("request",n,c,i),c})(N)});const j=N;var L=o(71225);const F=window._oc_config||{},U=document.getElementsByTagName("head")[0].getAttribute("data-user"),W=document.getElementsByTagName("head")[0].getAttribute("data-user-displayname"),Y=void 0!==U&&U;var q=o(39285),Q=o(36882),G=o(43627);const X={YES_NO_BUTTONS:70,OK_BUTTONS:71,FILEPICKER_TYPE_CHOOSE:1,FILEPICKER_TYPE_MOVE:2,FILEPICKER_TYPE_COPY:3,FILEPICKER_TYPE_COPY_MOVE:4,FILEPICKER_TYPE_CUSTOM:5,dialogsCounter:0,alert:function(t,e,n,i){this.message(t,e,"alert",X.OK_BUTTON,n,i)},info:function(t,e,n,i){this.message(t,e,"info",X.OK_BUTTON,n,i)},confirm:function(t,e,n,i){return this.message(t,e,"notice",X.YES_NO_BUTTONS,n,i)},confirmDestructive:function(t,e,n,i,o){return this.message(t,e,"none",n,i,void 0===o||o)},confirmHtml:function(t,e,n,i){return this.message(t,e,"notice",X.YES_NO_BUTTONS,n,i,!0)},prompt:function(e,n,i,o,r,s){return h().when(this._getMessageTemplate()).then((function(a){var c="oc-dialog-"+X.dialogsCounter+"-content",u="#"+c,d=a.octemplate({dialog_name:c,title:n,message:e,type:"notice"}),p=h()("");p.attr("type",s?"password":"text").attr("id",c+"-input").attr("placeholder",r);var A=h()("