Skip to content

Commit

Permalink
Add session timeout support
Browse files Browse the repository at this point in the history
Intercept all http responses and store expiration time from headers in
local storage. Drive expiration timers in app container across all
tabs with browser storage events and accompanying react hooks
integration. Show a warning with logout countdown and continue button
when session is nearly expired.
  • Loading branch information
jakemcdermott committed Sep 27, 2020
1 parent 24e9484 commit 87daac4
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 3 deletions.
27 changes: 27 additions & 0 deletions awx/ui_next/docs/APP_ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Application Architecture

## Local Storage Integration
The `useStorage` hook integrates with the browser's localStorage api.
It accepts a localStorage key as its only argument and returns a state
variable and setter function for that state variable. The hook enables
bidirectional data transfer between tabs via an event listener that
is registered with the Web Storage api.


![Sequence Diagram for useStorage](images/useStorage.png)

The `useStorage` hook currently lives in the `AppContainer` component. It
can be relocated to a more general location should and if the need
ever arise

## Session Expiration
Session timeout state is communicated to the client in the HTTP(S)
response headers. Every HTTP(S) response is intercepted to read the
session expiration time before being passed into the rest of the
application. A timeout date is computed from the intercepted HTTP(S)
headers and is pushed into local storage, where it can be read using
standard Web Storage apis or other utilities, such as `useStorage`.


![Sequence Diagram for session expiration](images/sessionExpiration.png)

Binary file added awx/ui_next/docs/images/sessionExpiration.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added awx/ui_next/docs/images/useStorage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions awx/ui_next/src/api/Base.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ const defaultHttp = axios.create({
},
});

defaultHttp.interceptors.response.use(response => {
const timeout = response?.headers['session-timeout'];
if (timeout) {
const timeoutDate = new Date().getTime() + timeout * 1000;
window.localStorage.setItem('session-timeout', String(timeoutDate));
window.dispatchEvent(new Event('storage'));
}
return response;
});

class Base {
constructor(http = defaultHttp, baseURL) {
this.http = http;
Expand Down
105 changes: 102 additions & 3 deletions awx/ui_next/src/components/AppContainer/AppContainer.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useHistory, useLocation, withRouter } from 'react-router-dom';
import {
Button,
Nav,
NavList,
Page,
Expand All @@ -13,13 +14,16 @@ import styled from 'styled-components';

import { ConfigAPI, MeAPI, RootAPI } from '../../api';
import { ConfigProvider } from '../../contexts/Config';
import { isAuthenticated } from '../../util/auth';
import About from '../About';
import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
import BrandLogo from './BrandLogo';
import NavExpandableGroup from './NavExpandableGroup';
import PageHeaderToolbar from './PageHeaderToolbar';

const SESSION_WARNING_DURATION = 1790;

const PageHeader = styled(PFPageHeader)`
& .pf-c-page__header-brand-link {
color: inherit;
Expand All @@ -34,6 +38,45 @@ const PageHeader = styled(PFPageHeader)`
}
`;

/**
* The useStorage hook integrates with the browser's localStorage api.
* It accepts a localStorage key as its only argument and returns a
* state variable and setter function for that state variable.
*
* This utility behaves much like the standard useState hook with some
* key differences:
* 1. You don't pass it an initial value. Instead, the provided key
* is used to retrieve the initial value from local storage. If
* the key doesn't exist in local storage, null is returned.
* 2. Behind the scenes, this hook registers an event listener with
* the Web Storage api to establish a two-way binding between the
* state variable and its corresponding local storage value. This
* means that updates to the state variable with the setter
* function will produce a corresponding update to the local
* storage value and vice-versa.
* 3. When local storage is shared across browser tabs, the data
* binding is also shared across browser tabs. This means that
* updates to the state variable using the setter function on
* one tab will also update the state variable on any other tab
* using this hook with the same key and vice-versa.
*/
function useStorage(key) {
const [storageVal, setStorageVal] = useState(
window.localStorage.getItem(key)
);
window.addEventListener('storage', () => {
const newVal = window.localStorage.getItem(key);
if (newVal !== storageVal) {
setStorageVal(newVal);
}
});
const setValue = val => {
window.localStorage.setItem(key, val);
setStorageVal(val);
};
return [storageVal, setValue];
}

function AppContainer({ i18n, navRouteConfig = [], children }) {
const history = useHistory();
const { pathname } = useLocation();
Expand All @@ -42,14 +85,51 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
const [isAboutModalOpen, setIsAboutModalOpen] = useState(false);
const [isReady, setIsReady] = useState(false);

const sessionTimeoutId = useRef();
const sessionIntervalId = useRef();
const [sessionTimeout, setSessionTimeout] = useStorage('session-timeout');
const [timeoutWarning, setTimeoutWarning] = useState(false);
const [timeRemaining, setTimeRemaining] = useState(Infinity);

const handleAboutModalOpen = () => setIsAboutModalOpen(true);
const handleAboutModalClose = () => setIsAboutModalOpen(false);
const handleConfigErrorClose = () => setConfigError(null);
const handleSessionTimeout = () => setTimeoutWarning(true);

const handleLogout = useCallback(async () => {
await RootAPI.logout();
history.replace('/login');
}, [history]);
setSessionTimeout(null);
}, [setSessionTimeout]);

const handleSessionContinue = () => {
MeAPI.read();
setTimeoutWarning(false);
};

useEffect(() => {
if (!isAuthenticated(document.cookie)) history.replace('/login');
const calcRemaining = () =>
parseInt(sessionTimeout, 10) - new Date().getTime();
const updateRemaining = () => setTimeRemaining(calcRemaining());
setTimeoutWarning(false);
clearTimeout(sessionTimeoutId.current);
clearInterval(sessionIntervalId.current);
sessionTimeoutId.current = setTimeout(
handleSessionTimeout,
calcRemaining() - SESSION_WARNING_DURATION * 1000
);
sessionIntervalId.current = setInterval(updateRemaining, 1000);
return () => {
clearTimeout(sessionTimeoutId.current);
clearInterval(sessionIntervalId.current);
};
}, [history, sessionTimeout]);

useEffect(() => {
if (timeRemaining <= 1) {
handleLogout();
}
}, [handleLogout, timeRemaining]);

useEffect(() => {
const loadConfig = async () => {
Expand Down Expand Up @@ -132,6 +212,25 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
{i18n._(t`Failed to retrieve configuration.`)}
<ErrorDetail error={configError} />
</AlertModal>
<AlertModal
title={i18n._(t`Your session is about to expire`)}
isOpen={timeoutWarning}
onClose={handleLogout}
variant="warning"
actions={[
<Button
key="confirm"
variant="primary"
onClick={handleSessionContinue}
>
Continue
</Button>,
]}
>
{i18n._(t`You will be logged out in`)}{' '}
{Number(Math.floor(timeRemaining / 1000))}{' '}
{i18n._(t`seconds due to inactivity.`)}
</AlertModal>
</>
);
}
Expand Down

0 comments on commit 87daac4

Please sign in to comment.