Update logout/login redirect for different users

* Logout as User A and Login as User B redirects to `/home'
* Logout as User A and Login as User A redirects to `/home'
* Allow session to timeout as User A and Login as User A redirects to User A's last location

See: https://github.com/ansible/awx/issues/11167
This commit is contained in:
nixocio
2022-03-14 14:47:47 -04:00
parent e94e15977c
commit d8037618c8
7 changed files with 154 additions and 14 deletions

View File

@@ -101,9 +101,9 @@ const AuthorizedRoutes = ({ routeConfig }) => {
export function ProtectedRoute({ children, ...rest }) { export function ProtectedRoute({ children, ...rest }) {
const { const {
authRedirectTo, authRedirectTo,
setAuthRedirectTo,
loginRedirectOverride,
isUserBeingLoggedOut, isUserBeingLoggedOut,
loginRedirectOverride,
setAuthRedirectTo,
} = useSession(); } = useSession();
const location = useLocation(); const location = useLocation();

View File

@@ -11,3 +11,4 @@ export const JOB_TYPE_URL_SEGMENTS = {
export const SESSION_TIMEOUT_KEY = 'awx-session-timeout'; export const SESSION_TIMEOUT_KEY = 'awx-session-timeout';
export const SESSION_REDIRECT_URL = 'awx-redirect-url'; export const SESSION_REDIRECT_URL = 'awx-redirect-url';
export const PERSISTENT_FILTER_KEY = 'awx-persistent-filter'; export const PERSISTENT_FILTER_KEY = 'awx-persistent-filter';
export const SESSION_USER_ID = 'awx-session-user-id';

View File

@@ -11,7 +11,7 @@ import { DateTime } from 'luxon';
import { RootAPI, MeAPI } from 'api'; import { RootAPI, MeAPI } from 'api';
import { isAuthenticated } from 'util/auth'; import { isAuthenticated } from 'util/auth';
import useRequest from 'hooks/useRequest'; import useRequest from 'hooks/useRequest';
import { SESSION_TIMEOUT_KEY } from '../constants'; import { SESSION_TIMEOUT_KEY, SESSION_USER_ID } from '../constants';
// The maximum supported timeout for setTimeout(), in milliseconds, // The maximum supported timeout for setTimeout(), in milliseconds,
// is the highest number you can represent as a signed 32bit // is the highest number you can represent as a signed 32bit
@@ -101,6 +101,7 @@ function SessionProvider({ children }) {
setIsUserBeingLoggedOut(true); setIsUserBeingLoggedOut(true);
if (!isSessionExpired.current) { if (!isSessionExpired.current) {
setAuthRedirectTo('/logout'); setAuthRedirectTo('/logout');
window.localStorage.setItem(SESSION_USER_ID, null);
} }
sessionStorage.clear(); sessionStorage.clear();
await RootAPI.logout(); await RootAPI.logout();
@@ -167,21 +168,21 @@ function SessionProvider({ children }) {
const sessionValue = useMemo( const sessionValue = useMemo(
() => ({ () => ({
isUserBeingLoggedOut,
loginRedirectOverride,
authRedirectTo, authRedirectTo,
handleSessionContinue, handleSessionContinue,
isSessionExpired, isSessionExpired,
isUserBeingLoggedOut,
loginRedirectOverride,
logout, logout,
sessionCountdown, sessionCountdown,
setAuthRedirectTo, setAuthRedirectTo,
}), }),
[ [
isUserBeingLoggedOut,
loginRedirectOverride,
authRedirectTo, authRedirectTo,
handleSessionContinue, handleSessionContinue,
isSessionExpired, isSessionExpired,
isUserBeingLoggedOut,
loginRedirectOverride,
logout, logout,
sessionCountdown, sessionCountdown,
setAuthRedirectTo, setAuthRedirectTo,

View File

@@ -1,5 +1,5 @@
/* eslint-disable react/jsx-no-useless-fragment */ /* eslint-disable react/jsx-no-useless-fragment */
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { Redirect, withRouter } from 'react-router-dom'; import { Redirect, withRouter } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -31,7 +31,8 @@ import { AuthAPI, RootAPI } from 'api';
import AlertModal from 'components/AlertModal'; import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail'; import ErrorDetail from 'components/ErrorDetail';
import { useSession } from 'contexts/Session'; import { useSession } from 'contexts/Session';
import { SESSION_REDIRECT_URL } from '../../constants'; import { getCurrentUserId } from 'util/auth';
import { SESSION_REDIRECT_URL, SESSION_USER_ID } from '../../constants';
const loginLogoSrc = 'static/media/logo-login.svg'; const loginLogoSrc = 'static/media/logo-login.svg';
@@ -43,6 +44,8 @@ const Login = styled(PFLogin)`
function AWXLogin({ alt, isAuthenticated }) { function AWXLogin({ alt, isAuthenticated }) {
const { authRedirectTo, isSessionExpired, setAuthRedirectTo } = useSession(); const { authRedirectTo, isSessionExpired, setAuthRedirectTo } = useSession();
const isNewUser = useRef(true);
const hasVerifiedUser = useRef(false);
const { const {
isLoading: isCustomLoginInfoLoading, isLoading: isCustomLoginInfoLoading,
@@ -112,8 +115,26 @@ function AWXLogin({ alt, isAuthenticated }) {
if (isCustomLoginInfoLoading) { if (isCustomLoginInfoLoading) {
return null; return null;
} }
if (isAuthenticated(document.cookie)) {
return <Redirect to={authRedirectTo || '/'} />; if (isAuthenticated(document.cookie) && !hasVerifiedUser.current) {
const currentUserId = getCurrentUserId(document.cookie);
const verifyIsNewUser = () => {
const previousUserId = JSON.parse(
window.localStorage.getItem(SESSION_USER_ID)
);
if (previousUserId === null) {
return true;
}
return currentUserId.toString() !== previousUserId.toString();
};
isNewUser.current = verifyIsNewUser();
hasVerifiedUser.current = true;
window.localStorage.setItem(SESSION_USER_ID, JSON.stringify(currentUserId));
}
if (isAuthenticated(document.cookie) && hasVerifiedUser.current) {
const redirect = isNewUser.current ? '/' : authRedirectTo;
return <Redirect to={redirect} />;
} }
let helperText; let helperText;

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { AuthAPI, RootAPI } from 'api'; import { AuthAPI, RootAPI } from 'api';
import { import {
@@ -7,9 +8,16 @@ import {
} from '../../../testUtils/enzymeHelpers'; } from '../../../testUtils/enzymeHelpers';
import AWXLogin from './Login'; import AWXLogin from './Login';
import { getCurrentUserId } from 'util/auth';
import { SESSION_USER_ID } from '../../constants';
jest.mock('../../api'); jest.mock('../../api');
jest.mock('util/auth', () => ({
getCurrentUserId: jest.fn(),
}));
RootAPI.readAssetVariables.mockResolvedValue({ RootAPI.readAssetVariables.mockResolvedValue({
data: { data: {
BRAND_NAME: 'AWX', BRAND_NAME: 'AWX',
@@ -72,6 +80,13 @@ describe('<Login />', () => {
custom_logo: 'images/foo.jpg', custom_logo: 'images/foo.jpg',
}, },
}); });
Object.defineProperty(window, 'localStorage', {
value: {
getItem: jest.fn(() => '42'),
setItem: jest.fn(() => null),
},
writable: true,
});
}); });
afterEach(() => { afterEach(() => {
@@ -276,15 +291,77 @@ describe('<Login />', () => {
expect(RootAPI.login).toHaveBeenCalledWith('un', 'pw'); expect(RootAPI.login).toHaveBeenCalledWith('un', 'pw');
}); });
test('render Redirect to / when already authenticated', async () => { test('render Redirect to / when already authenticated as a new user', async () => {
getCurrentUserId.mockReturnValue(1);
const history = createMemoryHistory({
initialEntries: ['/login'],
});
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => true} />); wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => true} />, {
context: {
router: { history },
session: {
authRedirectTo: '/projects',
handleSessionContinue: () => {},
isSessionExpired: false,
isUserBeingLoggedOut: false,
loginRedirectOverride: null,
logout: () => {},
sessionCountdown: 60,
setAuthRedirectTo: () => {},
},
},
});
}); });
expect(window.localStorage.getItem).toHaveBeenCalledWith(SESSION_USER_ID);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
SESSION_USER_ID,
'1'
);
await waitForElement(wrapper, 'Redirect', (el) => el.length === 1); await waitForElement(wrapper, 'Redirect', (el) => el.length === 1);
await waitForElement(wrapper, 'Redirect', (el) => el.props().to === '/'); await waitForElement(wrapper, 'Redirect', (el) => el.props().to === '/');
}); });
test('render redirect to authRedirectTo when authenticated as a previous user', async () => {
getCurrentUserId.mockReturnValue(42);
const history = createMemoryHistory({
initialEntries: ['/login'],
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => true} />, {
context: {
router: { history },
session: {
authRedirectTo: '/projects',
handleSessionContinue: () => {},
isSessionExpired: false,
isUserBeingLoggedOut: false,
loginRedirectOverride: null,
logout: () => {},
sessionCountdown: 60,
setAuthRedirectTo: () => {},
},
},
});
});
wrapper.update();
expect(window.localStorage.getItem).toHaveBeenCalledWith(SESSION_USER_ID);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
SESSION_USER_ID,
'42'
);
wrapper.update();
await waitForElement(wrapper, 'Redirect', (el) => el.length === 1);
await waitForElement(
wrapper,
'Redirect',
(el) => el.props().to === '/projects'
);
});
test('GitHub auth buttons shown', async () => { test('GitHub auth buttons shown', async () => {
AuthAPI.read.mockResolvedValue({ AuthAPI.read.mockResolvedValue({
data: { data: {

View File

@@ -6,3 +6,29 @@ export function isAuthenticated(cookie) {
} }
return false; return false;
} }
export function getCurrentUserId(cookie) {
if (!isAuthenticated(cookie)) {
return null;
}
const name = 'current_user';
let userId = null;
if (cookie && cookie !== '') {
const cookies = cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const parsedCookie = cookies[i].trim();
if (parsedCookie.substring(0, name.length + 1) === `${name}=`) {
userId = parseUserId(
decodeURIComponent(parsedCookie.substring(name.length + 1))
);
break;
}
}
}
return userId;
}
function parseUserId(decodedUserData) {
const userData = JSON.parse(decodedUserData);
return userData.id;
}

View File

@@ -1,4 +1,4 @@
import { isAuthenticated } from './auth'; import { isAuthenticated, getCurrentUserId } from './auth';
const invalidCookie = 'invalid'; const invalidCookie = 'invalid';
const validLoggedOutCookie = const validLoggedOutCookie =
@@ -19,3 +19,17 @@ describe('isAuthenticated', () => {
expect(isAuthenticated(validLoggedInCookie)).toEqual(true); expect(isAuthenticated(validLoggedInCookie)).toEqual(true);
}); });
}); });
describe('getCurrentUserId', () => {
test('returns null for invalid cookie', () => {
expect(getCurrentUserId(invalidCookie)).toEqual(null);
});
test('returns null for expired cookie', () => {
expect(getCurrentUserId(validLoggedOutCookie)).toEqual(null);
});
test('returns current user id for valid authenticated cookie', () => {
expect(getCurrentUserId(validLoggedInCookie)).toEqual(1);
});
});