From 3154d97dd1d4b93e2539992eb8c094a8f9aa9d51 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Wed, 10 Apr 2019 10:48:26 -0500 Subject: [PATCH] web: Fix login session persistence using localStorage. --- web/package.json | 2 ++ web/src/router.ts | 28 +++++++++++++--- web/src/store-modules/auth.ts | 62 +++++++++++++++++++++++++++-------- web/src/views/login.ts | 17 ++++------ 4 files changed, 79 insertions(+), 30 deletions(-) diff --git a/web/package.json b/web/package.json index c9db64b..6585ce1 100644 --- a/web/package.json +++ b/web/package.json @@ -13,12 +13,14 @@ "@fortawesome/free-solid-svg-icons": "^5.7.2", "@fortawesome/vue-fontawesome": "^0.1.5", "@types/js-cookie": "^2.2.1", + "@types/jwt-decode": "^2.2.1", "@types/lodash.assign": "^4.2.6", "@types/lodash.findindex": "^4.6.6", "@types/lodash.merge": "^4.6.5", "apexcharts": "^3.6.5", "axios": "^0.18.0", "js-cookie": "^2.2.0", + "jwt-decode": "^2.2.0", "keen-ui": "^1.1.2", "lodash.assign": "^4.2.0", "lodash.findindex": "^4.6.0", diff --git a/web/src/router.ts b/web/src/router.ts index cf7622b..0246f52 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -6,9 +6,12 @@ import Measures from '@/views/Measures.vue'; import UserAccount from '@/views/UserAccount.vue'; import QuickPanels from '@/views/QuickPanels.vue'; import { authStore } from '@/store'; +import { logService } from '@/services/logging'; Vue.use(Router); +const logger = logService.getLogger('/router'); + const router = new Router({ mode: 'history', base: process.env.BASE_URL, @@ -20,7 +23,8 @@ const router = new Router({ { path: '/login', name: 'login', - component: Login + component: Login, + props: (route) => ({ redirect: route.query.redirect }) }, { path: '/measures', @@ -43,12 +47,26 @@ const router = new Router({ ] }); +async function getAuthToken(): Promise { + if (authStore.authToken) { return authStore.authToken; } + + logger.trace('Checking for an existing API token.'); + try { + const token = await authStore.findLocalToken(); + return token; + } + catch (e) { + logger.info('Could not find a valid token.', e); + return null; + } +} + // Auth filter -router.beforeEach((to, from, next) => { +router.beforeEach(async (to, from, next) => { if (to.matched.some((record) => record.meta.requiresAuth)) { - if (!authStore.authToken) { - next({ name: 'login' }); - // params: { redirect: to.path } }); + const token = await getAuthToken(); + if (!token) { + next({ name: 'login', query: { redirect: to.path } }); } else { next(); } // if authed } else { next(); } // if auth required }); diff --git a/web/src/store-modules/auth.ts b/web/src/store-modules/auth.ts index 0a775fc..42e3f18 100644 --- a/web/src/store-modules/auth.ts +++ b/web/src/store-modules/auth.ts @@ -4,38 +4,72 @@ import { Mutation, VuexModule } from 'vuex-module-decorators'; -import Cookies from 'js-cookie'; +import JwtDecode from 'jwt-decode'; import { LoginSubmit } from '@/models'; import api from '@/services/pm-api-client'; import { logService } from '@/services/logging'; import { userStore, measureStore } from '@/store'; -const COOKIE_TOKEN = 'pm-api-cookie-token'; +const SESSION_KEY = 'session-token'; -const log = logService.getLogger('/store-modules/auth'); +const logger = logService.getLogger('/store-modules/auth'); + +export interface SessionToken { + iat: string; + exp: number; + sub: string; +} @Module({ namespaced: true, name: 'auth' }) export class AuthStoreModule extends VuexModule { public authToken: string | null = null; - @Action({ commit: 'SET_TOKEN', rawError: true }) - public async login(creds: LoginSubmit) { + @Action({ rawError: true }) + public async login(creds: LoginSubmit): Promise { const authToken = await api.createAuthToken(creds); // API will cache this token - Cookies.set(COOKIE_TOKEN, authToken, { secure: true }); - await userStore.fetchUser(); - await measureStore.fetchAllMeasures(); - log.trace('User login successful.'); + + // this should be guaranteed by the server (redirect HTTP -> HTTPS) + // but we'll do a sanity check just to make sure. + if (window.location.protocol === 'https:') { + localStorage.setItem(SESSION_KEY, authToken); + } + + logger.trace('User login successful.'); + this.context.commit('SET_TOKEN', authToken); + + this.loadInitialState(); return authToken; } - @Action({ commit: 'SET_TOKEN', rawError: true }) - public async findLocalToken() { - const token = Cookies.get(COOKIE_TOKEN); - if (token) { return token; } - else { throw new Error('No auth token stored as a cookie.'); } + @Action({ rawError: true }) + public async findLocalToken(): Promise { + const encodedToken = localStorage.getItem(SESSION_KEY); + if (!encodedToken) { + logger.trace('encodedToken was falsey'); + throw new Error('Could not find an existing auth token.'); + } + + const token = JwtDecode(encodedToken); + if ((new Date(token.exp * 1000)) < new Date()) { + logger.trace('token has expired: token.exp = ' + (token.exp * 1000)); + throw new Error ('The existing auth token has expired.'); + } + + api.setAuthToken(encodedToken); + this.context.commit('SET_TOKEN', encodedToken); + + this.loadInitialState(); + return encodedToken; + } + + @Action + public async loadInitialState() { + userStore.fetchUser(); + measureStore.fetchAllMeasures(); } @Mutation private SET_TOKEN(t: string) { + logger.info('SET_TOKEN called'); this.authToken = t; } } diff --git a/web/src/views/login.ts b/web/src/views/login.ts index 5416fb8..89e0526 100644 --- a/web/src/views/login.ts +++ b/web/src/views/login.ts @@ -4,10 +4,10 @@ import { LoginSubmit } from '@/models'; import { authStore } from '@/store'; import { logService } from '@/services/logging'; -const log = logService.getLogger('/views/login'); +const logger = logService.getLogger('/views/login'); @Component({}) -export default class Login extends Vue { +export class Login extends Vue { private loginForm: LoginSubmit = { email: '', @@ -16,7 +16,7 @@ export default class Login extends Vue { private waiting = false; private flashMessage = ''; - private redirect: string | undefined = undefined; + @Prop() private redirect!: string | null; public async login() { this.waiting = true; @@ -29,18 +29,13 @@ export default class Login extends Vue { this.flashMessage = 'invlid username or password'; } else { this.flashMessage = 'unable to log you in'; - log.error(e); + logger.error(e); } } this.waiting = false; } - - /* - @Watch('$route', { immediate: true }) - private onRouteChange(route: Route) { - this.redirect = route.query && route.query.redirect as string; - } - */ } // TODO: styling of flash message + +export default Login;