Merge pull request #8250 from jakemcdermott/session-timeout

Add session timeout support

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-12-15 20:56:34 +00:00 committed by GitHub
commit de0967a587
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 222 additions and 3 deletions

View File

@ -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 not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -1,6 +1,13 @@
import axios from 'axios';
import { SESSION_TIMEOUT_KEY } from '../constants';
import { encodeQueryString } from '../util/qs';
import debounce from '../util/debounce';
const updateStorage = debounce((key, val) => {
window.localStorage.setItem(key, val);
window.dispatchEvent(new Event('storage'));
}, 500);
const defaultHttp = axios.create({
xsrfCookieName: 'csrftoken',
@ -10,6 +17,15 @@ const defaultHttp = axios.create({
},
});
defaultHttp.interceptors.response.use(response => {
const timeout = response?.headers['session-timeout'];
if (timeout) {
const timeoutDate = new Date().getTime() + timeout * 1000;
updateStorage(SESSION_TIMEOUT_KEY, String(timeoutDate));
}
return response;
});
class Base {
constructor(http = defaultHttp, baseURL) {
this.http = http;

View File

@ -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,
@ -13,6 +14,8 @@ import styled from 'styled-components';
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
import { ConfigProvider } from '../../contexts/Config';
import { SESSION_TIMEOUT_KEY } from '../../constants';
import { isAuthenticated } from '../../util/auth';
import About from '../About';
import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
@ -20,6 +23,17 @@ import BrandLogo from './BrandLogo';
import NavExpandableGroup from './NavExpandableGroup';
import PageHeaderToolbar from './PageHeaderToolbar';
// The maximum supported timeout for setTimeout(), in milliseconds,
// is the highest number you can represent as a signed 32bit
// integer (approximately 25 days)
const MAX_TIMEOUT = 2 ** (32 - 1) - 1;
// The number of seconds the session timeout warning is displayed
// before the user is logged out. Increasing this number (up to
// the total session time, which is 1800s by default) will cause
// the session timeout warning to display sooner.
const SESSION_WARNING_DURATION = 10;
const PageHeader = styled(PFPageHeader)`
& .pf-c-page__header-brand-link {
color: inherit;
@ -30,6 +44,45 @@ const PageHeader = styled(PFPageHeader)`
}
`;
/**
* The useStorage hook integrates with the browser's localStorage api.
* It accepts a storage 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();
@ -38,14 +91,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_KEY);
const [timeoutWarning, setTimeoutWarning] = useState(false);
const [timeRemaining, setTimeRemaining] = useState(null);
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,
Math.min(calcRemaining() - SESSION_WARNING_DURATION * 1000, MAX_TIMEOUT)
);
sessionIntervalId.current = setInterval(updateRemaining, 1000);
return () => {
clearTimeout(sessionTimeoutId.current);
clearInterval(sessionIntervalId.current);
};
}, [history, sessionTimeout]);
useEffect(() => {
if (timeRemaining !== null && timeRemaining <= 1) {
handleLogout();
}
}, [handleLogout, timeRemaining]);
useEffect(() => {
const loadConfig = async () => {
@ -128,6 +218,31 @@ 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 && sessionTimeout > 0 && timeRemaining !== null}
onClose={handleLogout}
showClose={false}
variant="warning"
actions={[
<Button
key="confirm"
variant="primary"
onClick={handleSessionContinue}
>
{i18n._(t`Continue`)}
</Button>,
<Button key="logout" variant="secondary" onClick={handleLogout}>
{i18n._(t`Logout`)}
</Button>,
]}
>
{i18n._(
t`You will be logged out in ${Number(
Math.max(Math.floor(timeRemaining / 1000), 0)
)} seconds due to inactivity.`
)}
</AlertModal>
</>
);
}

View File

@ -7,3 +7,5 @@ export const JOB_TYPE_URL_SEGMENTS = {
ad_hoc_command: 'command',
workflow_job: 'workflow',
};
export const SESSION_TIMEOUT_KEY = 'awx-session-timeout';

View File

@ -0,0 +1,19 @@
/**
* The debounce utility creates a debounced version of the provided
* function. The debounced function delays invocation until after
* the given time interval (milliseconds) has elapsed since the last
* time the function was called. This means that if you call the
* debounced function repeatedly, it will only run once after it
* stops being called.
*/
const debounce = (func, interval) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
func(...args);
}, interval);
};
};
export default debounce;

View File

@ -0,0 +1,40 @@
import debounce from './debounce';
jest.useFakeTimers();
describe('debounce', () => {
test('it debounces', () => {
let count = 0;
const func = increment => {
count += increment;
};
const debounced = debounce(func, 1000);
debounced(2);
debounced(2);
debounced(2);
debounced(2);
debounced(2);
expect(count).toEqual(0);
jest.advanceTimersByTime(1000);
expect(count).toEqual(2);
debounced(2);
debounced(2);
debounced(2);
debounced(2);
debounced(2);
jest.advanceTimersByTime(1000);
debounced(2);
debounced(2);
debounced(2);
debounced(2);
debounced(2);
jest.advanceTimersByTime(1000);
debounced(2);
debounced(2);
debounced(2);
debounced(2);
debounced(2);
jest.advanceTimersByTime(1000);
expect(count).toEqual(8);
});
});