diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index 4608c0280..91c3f266c 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -15,6 +15,7 @@ "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/vue-fontawesome": "^2.0.10", "@mdi/font": "^6.9.96", + "@vueuse/core": "^10.5.0", "axios": "^0.27.2", "bootstrap-scss": "^4.6.2", "core-js": "^3.30.2", @@ -3824,6 +3825,11 @@ "vue": "*" } }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.18.tgz", + "integrity": "sha512-v/ZHEj9xh82usl8LMR3GarzFY1IrbXJw5L4QfQhokjRV91q+SelFqxQWSep1ucXEZ22+dSTwLFkXeur25sPIbw==" + }, "node_modules/@types/webpack": { "version": "4.41.33", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.33.tgz", @@ -4581,6 +4587,89 @@ "integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==", "dev": true }, + "node_modules/@vueuse/core": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.5.0.tgz", + "integrity": "sha512-z/tI2eSvxwLRjOhDm0h/SXAjNm8N5ld6/SC/JQs6o6kpJ6Ya50LnEL8g5hoYu005i28L0zqB5L5yAl8Jl26K3A==", + "dependencies": { + "@types/web-bluetooth": "^0.0.18", + "@vueuse/metadata": "10.5.0", + "@vueuse/shared": "10.5.0", + "vue-demi": ">=0.14.6" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz", + "integrity": "sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.5.0.tgz", + "integrity": "sha512-fEbElR+MaIYyCkeM0SzWkdoMtOpIwO72x8WsZHRE7IggiOlILttqttM69AS13nrDxosnDBYdyy3C5mR1LCxHsw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.5.0.tgz", + "integrity": "sha512-18iyxbbHYLst9MqU1X1QNdMHIjks6wC7XTVf0KNOv5es/Ms6gjVFCAAWTVP2JStuGqydg3DT+ExpFORUEi9yhg==", + "dependencies": { + "vue-demi": ">=0.14.6" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz", + "integrity": "sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", diff --git a/app/frontend/package.json b/app/frontend/package.json index 8425bf9e4..65b3484a0 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -31,6 +31,7 @@ "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/vue-fontawesome": "^2.0.10", "@mdi/font": "^6.9.96", + "@vueuse/core": "^10.5.0", "axios": "^0.27.2", "bootstrap-scss": "^4.6.2", "core-js": "^3.30.2", diff --git a/app/frontend/src/App.vue b/app/frontend/src/App.vue index 64d04ac94..dc9346694 100755 --- a/app/frontend/src/App.vue +++ b/app/frontend/src/App.vue @@ -8,6 +8,7 @@ + @@ -18,6 +19,7 @@ import BCGovHeader from '@/components/bcgov/BCGovHeader.vue'; import BCGovFooter from '@/components/bcgov/BCGovFooter.vue'; import BCGovNavBar from '@/components/bcgov/BCGovNavBar.vue'; +import BaseWarningDialog from '@/components/base/BaseWarningDialog.vue'; export default { name: 'App', @@ -25,6 +27,7 @@ export default { BCGovHeader, BCGovFooter, BCGovNavBar, + BaseWarningDialog, }, }; diff --git a/app/frontend/src/components/base/BaseWarningDialog.vue b/app/frontend/src/components/base/BaseWarningDialog.vue new file mode 100644 index 000000000..faa964bc9 --- /dev/null +++ b/app/frontend/src/components/base/BaseWarningDialog.vue @@ -0,0 +1,48 @@ + + { + setTokenExpirationWarningDialog({ + showTokenExpiredWarningMSg: false, + resetToken: false, + }); + } + " + @continue-dialog=" + () => { + setTokenExpirationWarningDialog({ + showTokenExpiredWarningMSg: false, + resetToken: true, + }); + } + " + > + Session expiring + + + Your session will expire soon and you will be signed out automatically. + + Do you want to stay signed in? + + + Confirm + + + + diff --git a/app/frontend/src/plugins/keycloak.js b/app/frontend/src/plugins/keycloak.js index f278ca713..91e34031b 100755 --- a/app/frontend/src/plugins/keycloak.js +++ b/app/frontend/src/plugins/keycloak.js @@ -29,6 +29,8 @@ export default { loginFn: null, login: null, createLoginUrl: null, + updateToken: null, + clearToken: null, createLogoutUrl: null, createRegisterUrl: null, register: null, @@ -81,17 +83,7 @@ function init(config, watch, options) { watch.$emit('ready', options.onReady.bind(this, keycloak)); }; keycloak.onAuthSuccess = function () { - // Check token validity every 10 seconds (10 000 ms) and, if necessary, update the token. - // Refresh token if it's valid for less then 60 seconds - const updateTokenInterval = setInterval( - () => - keycloak.updateToken(60).catch(() => { - keycloak.clearToken(); - }), - 10000 - ); watch.logoutFn = () => { - clearInterval(updateTokenInterval); keycloak.logout( options.logout || { redirectUri: config['logoutRedirectUri'] } ); @@ -129,6 +121,8 @@ function init(config, watch, options) { watch.realmAccess = keycloak.realmAccess; watch.resourceAccess = keycloak.resourceAccess; watch.refreshToken = keycloak.refreshToken; + watch.updateToken = keycloak.updateToken; + watch.clearToken = keycloak.clearToken; watch.refreshTokenParsed = keycloak.refreshTokenParsed; watch.timeSkew = keycloak.timeSkew; watch.responseMode = keycloak.responseMode; diff --git a/app/frontend/src/router/index.js b/app/frontend/src/router/index.js index 661f8d771..a5dd04f7c 100755 --- a/app/frontend/src/router/index.js +++ b/app/frontend/src/router/index.js @@ -466,6 +466,7 @@ export default function getRouter(basePath = '/') { router.app.$keycloak.authenticated ) { store.dispatch('form/getFormsForCurrentUser'); + store.dispatch('auth/checkTokenExpiration'); } // Handle proper redirections on first page load diff --git a/app/frontend/src/store/modules/auth.js b/app/frontend/src/store/modules/auth.js index 52b24fc80..00ff755bc 100755 --- a/app/frontend/src/store/modules/auth.js +++ b/app/frontend/src/store/modules/auth.js @@ -1,5 +1,8 @@ import Vue from 'vue'; import getRouter from '@/router'; +import { useIdle, useTimestamp, watchPausable } from '@vueuse/core'; +import { ref } from 'vue'; +import moment from 'moment'; /** * @function hasRoles @@ -20,6 +23,10 @@ export default { // In most cases, when this becomes populated, we end up doing a redirect flow, // so when we return to the app, it is fresh again and undefined redirectUri: undefined, + showTokenExpiredWarningMSg: false, + inActiveCheckInterval: null, + updateTokenInterval: null, + watchPausable: null, }, getters: { authenticated: () => Vue.prototype.$keycloak.authenticated, @@ -27,6 +34,9 @@ export default { Vue.prototype.$keycloak.createLoginUrl(options), createLogoutUrl: () => (options) => Vue.prototype.$keycloak.createLogoutUrl(options), + updateToken: () => (minValidity) => + Vue.prototype.$keycloak.updateToken(minValidity), + clearToken: () => () => Vue.prototype.$keycloak.clearToken(), email: () => Vue.prototype.$keycloak.tokenParsed ? Vue.prototype.$keycloak.tokenParsed.email @@ -83,11 +93,17 @@ export default { return user; }, + showTokenExpiredWarningMSg: (state) => state.showTokenExpiredWarningMSg, + inActiveCheckInterval: (state) => state.inActiveCheckInterval, + updateTokenInterval: (state) => state.updateTokenInterval, }, mutations: { SET_REDIRECTURI(state, redirectUri) { state.redirectUri = redirectUri; }, + SET_SHOW_TOKEN_EXPIRED_WARNING_MSG(state, showTokenExpiredWarningMSg) { + state.showTokenExpiredWarningMSg = showTokenExpiredWarningMSg; + }, }, actions: { // TODO: Ideally move this to notifications module, but some strange interactions with lazy loading in unit tests @@ -141,5 +157,59 @@ export default { ); } }, + async setTokenExpirationWarningDialog( + { getters, commit, dispatch, state }, + { showTokenExpiredWarningMSg, resetToken } + ) { + if (!showTokenExpiredWarningMSg && resetToken) { + state.watchPausable.resume(); + getters.updateToken(-1).catch(() => { + getters.clearToken(); + dispatch('logout'); + }); + } else if (!resetToken) { + clearInterval(getters.updateTokenInterval); + clearInterval(getters.inActiveCheckInterval); + dispatch('logout'); + } + commit('SET_SHOW_TOKEN_EXPIRED_WARNING_MSG', showTokenExpiredWarningMSg); + if (showTokenExpiredWarningMSg) { + setTimeout(() => { + dispatch('logout'); + }, 180000); + } + }, + async checkTokenExpiration({ getters, dispatch, state }) { + if (getters.authenticated) { + const { idle, lastActive } = useIdle(1000, { initialState: true }); + const source = ref(idle); + const now = useTimestamp({ interval: 1000 }); + state.watchPausable = watchPausable(source, (value) => { + if (value) { + clearInterval(getters.updateTokenInterval); + state.inActiveCheckInterval = setInterval(() => { + let end = moment(now.value); + let active = moment(lastActive.value); + let duration = moment.duration(end.diff(active)).as('minutes'); + if (duration > 1) { + state.watchPausable.pause(); + dispatch('setTokenExpirationWarningDialog', { + showTokenExpiredWarningMSg: true, + resetToken: true, + }); + } + }, 60000); + } else { + clearInterval(getters.inActiveCheckInterval); + state.updateTokenInterval = setInterval(() => { + getters.updateToken(-1).catch(() => { + getters.clearToken(); + }); + }, 120000); + } + }); + state.watchPausable.resume(); + } + }, }, };