diff --git a/awx/ui/src/App.js b/awx/ui/src/App.js index bf1701eec90d..5f35ecfbc2d7 100644 --- a/awx/ui/src/App.js +++ b/awx/ui/src/App.js @@ -101,9 +101,9 @@ const AuthorizedRoutes = ({ routeConfig }) => { export function ProtectedRoute({ children, ...rest }) { const { authRedirectTo, - setAuthRedirectTo, - loginRedirectOverride, isUserBeingLoggedOut, + loginRedirectOverride, + setAuthRedirectTo, } = useSession(); const location = useLocation(); diff --git a/awx/ui/src/constants.js b/awx/ui/src/constants.js index 303061e3ea40..d4d7259a4df8 100644 --- a/awx/ui/src/constants.js +++ b/awx/ui/src/constants.js @@ -11,3 +11,4 @@ export const JOB_TYPE_URL_SEGMENTS = { export const SESSION_TIMEOUT_KEY = 'awx-session-timeout'; export const SESSION_REDIRECT_URL = 'awx-redirect-url'; export const PERSISTENT_FILTER_KEY = 'awx-persistent-filter'; +export const SESSION_USER_ID = 'awx-session-user-id'; diff --git a/awx/ui/src/contexts/Session.js b/awx/ui/src/contexts/Session.js index 3a33fb3f6294..af3494831346 100644 --- a/awx/ui/src/contexts/Session.js +++ b/awx/ui/src/contexts/Session.js @@ -11,7 +11,7 @@ import { DateTime } from 'luxon'; import { RootAPI, MeAPI } from 'api'; import { isAuthenticated } from 'util/auth'; 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, // is the highest number you can represent as a signed 32bit @@ -101,6 +101,7 @@ function SessionProvider({ children }) { setIsUserBeingLoggedOut(true); if (!isSessionExpired.current) { setAuthRedirectTo('/logout'); + window.localStorage.setItem(SESSION_USER_ID, null); } sessionStorage.clear(); await RootAPI.logout(); @@ -167,21 +168,21 @@ function SessionProvider({ children }) { const sessionValue = useMemo( () => ({ - isUserBeingLoggedOut, - loginRedirectOverride, authRedirectTo, handleSessionContinue, isSessionExpired, + isUserBeingLoggedOut, + loginRedirectOverride, logout, sessionCountdown, setAuthRedirectTo, }), [ - isUserBeingLoggedOut, - loginRedirectOverride, authRedirectTo, handleSessionContinue, isSessionExpired, + isUserBeingLoggedOut, + loginRedirectOverride, logout, sessionCountdown, setAuthRedirectTo, diff --git a/awx/ui/src/screens/Login/Login.js b/awx/ui/src/screens/Login/Login.js index aae185155927..c19d02d9b168 100644 --- a/awx/ui/src/screens/Login/Login.js +++ b/awx/ui/src/screens/Login/Login.js @@ -1,5 +1,5 @@ /* 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 { t } from '@lingui/macro'; @@ -31,7 +31,8 @@ import { AuthAPI, RootAPI } from 'api'; import AlertModal from 'components/AlertModal'; import ErrorDetail from 'components/ErrorDetail'; 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'; @@ -43,6 +44,8 @@ const Login = styled(PFLogin)` function AWXLogin({ alt, isAuthenticated }) { const { authRedirectTo, isSessionExpired, setAuthRedirectTo } = useSession(); + const isNewUser = useRef(true); + const hasVerifiedUser = useRef(false); const { isLoading: isCustomLoginInfoLoading, @@ -112,8 +115,26 @@ function AWXLogin({ alt, isAuthenticated }) { if (isCustomLoginInfoLoading) { return null; } - if (isAuthenticated(document.cookie)) { - return ; + + 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 ; } let helperText; diff --git a/awx/ui/src/screens/Login/Login.test.js b/awx/ui/src/screens/Login/Login.test.js index bc168b5e3f10..7768b2ae4b84 100644 --- a/awx/ui/src/screens/Login/Login.test.js +++ b/awx/ui/src/screens/Login/Login.test.js @@ -1,4 +1,5 @@ import React from 'react'; +import { createMemoryHistory } from 'history'; import { act } from 'react-dom/test-utils'; import { AuthAPI, RootAPI } from 'api'; import { @@ -7,9 +8,16 @@ import { } from '../../../testUtils/enzymeHelpers'; import AWXLogin from './Login'; +import { getCurrentUserId } from 'util/auth'; + +import { SESSION_USER_ID } from '../../constants'; jest.mock('../../api'); +jest.mock('util/auth', () => ({ + getCurrentUserId: jest.fn(), +})); + RootAPI.readAssetVariables.mockResolvedValue({ data: { BRAND_NAME: 'AWX', @@ -72,6 +80,13 @@ describe('', () => { custom_logo: 'images/foo.jpg', }, }); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: jest.fn(() => '42'), + setItem: jest.fn(() => null), + }, + writable: true, + }); }); afterEach(() => { @@ -276,15 +291,77 @@ describe('', () => { 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; await act(async () => { - wrapper = mountWithContexts( true} />); + wrapper = mountWithContexts( 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.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( 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 () => { AuthAPI.read.mockResolvedValue({ data: { diff --git a/awx/ui/src/util/auth.js b/awx/ui/src/util/auth.js index e8d9f4dea77c..394737587e98 100644 --- a/awx/ui/src/util/auth.js +++ b/awx/ui/src/util/auth.js @@ -6,3 +6,29 @@ export function isAuthenticated(cookie) { } 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; +} diff --git a/awx/ui/src/util/auth.test.js b/awx/ui/src/util/auth.test.js index 01d15b0b0f0e..db0d2bf24523 100644 --- a/awx/ui/src/util/auth.test.js +++ b/awx/ui/src/util/auth.test.js @@ -1,4 +1,4 @@ -import { isAuthenticated } from './auth'; +import { isAuthenticated, getCurrentUserId } from './auth'; const invalidCookie = 'invalid'; const validLoggedOutCookie = @@ -19,3 +19,17 @@ describe('isAuthenticated', () => { 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); + }); +});