Skip to content

Commit

Permalink
Jwt authentication (#1042)
Browse files Browse the repository at this point in the history
* Configure jwt token with Joken library

* Wip on pow auth flow refactoring for jwt

* Remove all Pow browser session routes

* wip on me endpoint

* Me endpoint in session controller

* Add OpenApi docs to the session controller

* Add Bearer security scheme on main openapi spec

* Fix login action endpoint openapi spex

* Moved Jwt related authorization to the web module

* Configure expiration time of jwt access token

* Login endpoint returns access token expiration in seconds

* User id as sub claim in access token jwt

* Add typ set to Bearer for access token jwt

* Refresh token flow in SessionController

* Add tests for AccessToken module

* Fix AuthenticateAPIKeyPlug test

* Add tests for RefreshToken

* Add tests for JWTAuthPlug

* SessionController tests

* User slice state with Login Saga

* Login component performs login against api

* Refresh token flow in SPA

* Add tests for login component

* Auth Guard react router component

* Upgraded Cypress to version 12. E2E tests with jwt authentication refactored

The new tests follows the guideline/directory structure of cypress 12.
The use of the new session api allows to integration and testing of the jwt authentication
across all the screens.

* Host Details exporter status fetching use proper network client

* Implemented request_path behavior for redirect to the previous path when session expires

* Disabled jwt authentication in testing env

* Fix code misspellings

* Misc code style refactors

* Fix formatting issues

* Refactored cypress accept eula procedure to use the direct API

* Increase timeout on cypress e2e tests for clusters overview

* Address review suggestions about the jwt authentication flow

* Removed old pow session login eex template

* Address review testing suggestion of the jwt flow

* Address frontend comments from review

* Addressing review comments about e2e cypress tests

* Moved logout function outside the Layout component scope

* Removed old trento frontend paths
  • Loading branch information
CDimonaco authored Jan 12, 2023
1 parent fdb054e commit 46262a0
Show file tree
Hide file tree
Showing 62 changed files with 7,637 additions and 1,179 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('ClusterDetails ClusterSettingsNew component', () => {
<ClusterSettingsNew />,
{
...defaultInitialState,
catalogNew: { loading: false, data: catalog, error: null }
catalogNew: { loading: false, data: catalog, error: null },
}
);

Expand Down
47 changes: 47 additions & 0 deletions assets/js/components/Guard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Outlet, useNavigate } from 'react-router-dom';
import { setUserAsLogged } from '@state/user';
import { clearCredentialsFromStore } from '@lib/auth';

export default function Guard({ redirectPath, getUser }) {
const [user, setUser] = useState(null);
const [userLoading, setUserLoading] = useState(true);
const dispatch = useDispatch();
const { loggedIn } = useSelector((state) => state.user);
const navigate = useNavigate();

useEffect(() => {
getUser()
.then((trentoUser) => {
setUser(trentoUser);
setUserLoading(false);
// If the user in the store is already loggedIn, means
// that the store is hydrated so the guard is triggered on the spa full loaded
// no dispatching of logged in action needed
if (!loggedIn) {
dispatch(setUserAsLogged());
}
})
.catch(() => {
setUserLoading(false);
clearCredentialsFromStore();
});
}, [loggedIn]);

useEffect(() => {
if (!userLoading && !user) {
const currentLocationPath = new URLSearchParams();
currentLocationPath.append('request_path', window.location.pathname);
navigate(`${redirectPath}?${currentLocationPath.toString()}`, {
replace: true,
});
}
}, [userLoading, user]);

if (user) {
return <Outlet />;
}

return null;
}
93 changes: 93 additions & 0 deletions assets/js/components/Guard.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from 'react';

import { screen, waitFor } from '@testing-library/react';
import 'intersection-observer';
import '@testing-library/jest-dom';
import { act } from 'react-dom/test-utils';
import { Route, Routes } from 'react-router-dom';
import { renderWithRouter, withState } from '@lib/test-utils';
import Guard from './Guard';

describe('Guard component', () => {
it('should redirect to the redirectPath prop plus the current pathname if present when the user cannot be loaded', async () => {
const badGetUser = () => Promise.reject(Error('the reason is you'));
const [StatefulGuard] = withState(
<Routes>
<Route
element={<Guard redirectPath="/session/new" getUser={badGetUser} />}
>
<Route
path="/"
element={<div data-testid="inner-component"> test </div>}
/>
</Route>
</Routes>,
{
user: {
loggedIn: false,
},
}
);

renderWithRouter(StatefulGuard, '/asd');

await act(() => {});

expect(window.location.pathname).toEqual('/session/new');
expect(window.location.search).toEqual('?request_path=%2F');
});

it('should redirect to the redirectPath prop when the user cannot be loaded', async () => {
const badGetUser = () => Promise.reject(Error('the reason is you'));
const [StatefulGuard] = withState(
<Routes>
<Route
element={<Guard redirectPath="/session/new" getUser={badGetUser} />}
>
<Route
path="/"
element={<div data-testid="inner-component"> test </div>}
/>
</Route>
</Routes>,
{
user: {
loggedIn: false,
},
}
);

renderWithRouter(StatefulGuard);

await act(() => {});

expect(window.location.pathname).toEqual('/session/new');
});

it('should render the outlet when the user can be loaded', async () => {
const goodGetUser = () => Promise.resolve({ username: 'admin' });
const [StatefulGuard] = withState(
<Routes>
<Route
element={<Guard redirectPath="/session/new" getUser={goodGetUser} />}
>
<Route
path="/"
element={<div data-testid="inner-component"> test </div>}
/>
</Route>
</Routes>,
{
user: {
loggedIn: false,
},
}
);

renderWithRouter(StatefulGuard);

await waitFor(() => {
expect(screen.getByTestId('inner-component')).toBeDefined();
});
});
});
6 changes: 4 additions & 2 deletions assets/js/components/HostDetails/HostDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';

import axios from 'axios';
import { networkClient } from '@lib/network';

import ListView from '@components/ListView';
import Table from '@components/Table';
Expand Down Expand Up @@ -34,7 +34,9 @@ function HostDetails() {
const [exportersStatus, setExportersStatus] = useState([]);

const getExportersStatus = async () => {
const { data } = await axios.get(`/api/hosts/${hostID}/exporters_status`);
const { data } = await networkClient.get(
`/api/hosts/${hostID}/exporters_status`
);
setExportersStatus(data);
};

Expand Down
16 changes: 11 additions & 5 deletions assets/js/components/HostsList.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,26 @@ describe('HostsLists component', () => {
cluster: '',
sid: 'NWQ',
version: '1.1.0+git.dev17.1660137228.fe5ba8a',
}
},
].forEach(({ host, ip, provider, cluster, sid, version }) => {
it(`should show the correct values in the hosts list for host ${host}`, () => {
const [StatefulHostsList] = withDefaultState(<HostsList />);
const params = { route: `/hosts?hostname=${host}` }
const params = { route: `/hosts?hostname=${host}` };
renderWithRouter(StatefulHostsList, params);

const table = screen.getByRole('table');
expect(table.querySelector('td:nth-child(2)')).toHaveTextContent(host);
expect(table.querySelector('td:nth-child(3)')).toHaveTextContent(ip);
expect(table.querySelector('td:nth-child(4)')).toHaveTextContent(provider);
expect(table.querySelector('td:nth-child(5)')).toHaveTextContent(cluster);
expect(table.querySelector('td:nth-child(4)')).toHaveTextContent(
provider
);
expect(table.querySelector('td:nth-child(5)')).toHaveTextContent(
cluster
);
expect(table.querySelector('td:nth-child(6)')).toHaveTextContent(sid);
expect(table.querySelector('td:nth-child(7)')).toHaveTextContent(version);
expect(table.querySelector('td:nth-child(7)')).toHaveTextContent(
version
);
});
});
});
Expand Down
20 changes: 12 additions & 8 deletions assets/js/components/Layout/Layout.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/anchor-is-valid */
import React, { useState, useCallback } from 'react';
import { NavLink, Outlet } from 'react-router-dom';

Expand All @@ -17,6 +18,7 @@ import {
import TrentoLogo from '@static/trento-logo-stacked.svg';

import classNames from 'classnames';
import { clearCredentialsFromStore } from '@lib/auth';

const navigation = [
{ name: 'Dashboard', href: '/', icon: EOS_HOME_OUTLINED },
Expand Down Expand Up @@ -53,6 +55,13 @@ const navigation = [
{ name: 'About', href: '/about', icon: EOS_INFO },
];

const logout = (e) => {
e.preventDefault();

clearCredentialsFromStore();
window.location.href = '/session/new';
};

function Layout() {
const [isCollapsed, setCollapsed] = useState(
localStorage.getItem('sidebar-collapsed')
Expand All @@ -65,10 +74,6 @@ function Layout() {
: localStorage.setItem('sidebar-collapsed', true);
}, [isCollapsed]);

const csrfToken = document.head.querySelector(
'[name~=csrf-token][content]'
).content;

const sidebarIconColor = 'currentColor';
const sidebarIconClassName = 'text-gray-400 hover:text-gray-300';
const sidebarIconSize = '24';
Expand Down Expand Up @@ -157,10 +162,9 @@ function Layout() {
<div className="relative flex flex-col justify-end h-full px-8 md:w-full">
<div className="relative p-5 flex items-center w-full space-x-8 justify-end mr-20">
<a
href="/session"
data-to="/session"
data-method="delete"
data-csrf={csrfToken}
role="button"
aria-hidden="true"
onClick={logout}
className="flex text-md text-gray-500 hover:text-gray-700"
>
Sign out
Expand Down
108 changes: 108 additions & 0 deletions assets/js/components/Login.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import React, { useState, useEffect } from 'react';
import TrentoLogo from '@static/trento.svg';
import { useDispatch, useSelector } from 'react-redux';
import { toast } from 'react-hot-toast';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { performLogin } from '@state/actions/auth';

export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { authError, authInProgress, loggedIn } = useSelector(
(state) => state.user
);
const dispatch = useDispatch();
const navigate = useNavigate();
const [searchParams] = useSearchParams();

useEffect(() => {
if (authError) {
toast.error('An error occurred during login, try again');
}
}, [authError]);

useEffect(() => {
if (loggedIn) {
const destinationURL = searchParams.get('request_path');
navigate(destinationURL || '/');
}
}, [loggedIn]);

const handleLoginSubmit = (e) => {
e.preventDefault();
dispatch(performLogin({ username, password }));
};

return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<img className="mx-auto h-12 w-auto" src={TrentoLogo} alt="Trento" />
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Login to Trento
</h2>
</div>

<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={handleLoginSubmit}>
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700"
>
Username
</label>
<div className="mt-1">
<input
id="username"
type="text"
data-testid="login-username"
disabled={authInProgress}
value={username}
onChange={(e) => setUsername(e.target.value)}
name="username"
autoComplete="username"
required
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm"
/>
</div>
</div>

<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<div className="mt-1">
<input
id="password"
type="password"
data-testid="login-password"
disabled={authInProgress}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={authInProgress}
data-testid="login-submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-jungle-green-500 hover:opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-jungle-green-500"
>
Login
</button>
</div>
</form>
</div>
</div>
</div>
);
}
Loading

0 comments on commit 46262a0

Please sign in to comment.