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') + }) + +}) 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()("