Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make Authenticated component secure by default #10251

Merged
merged 2 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Make Authenticated component secure by default
## Problem

`<Authenticated>` displays its child while the auth status is being checked. It may potentially render restricted content to anonymous users.

## Solution

Make it render nothing while loading.
  • Loading branch information
fzaninotto committed Oct 1, 2024
commit c88e25967dbce7014c508e9dc1d23d5adc86441f
15 changes: 13 additions & 2 deletions docs/Authenticated.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ title: "The Authenticated Component"

# `<Authenticated>`

The `<Authenticated>` component calls [the `useAuthState()` hook](./useAuthState.md), and by default optimistically renders its child component - unless the authentication check fails. Use it as an alternative to the `useAuthenticated()` hook when you can't use a hook, or you want to support pessimistic mode by setting `requireAuth` prop, e.g. inside a `<Route element>` commponent:
The `<Authenticated>` component calls [`authProvider.checkAuth()`](./Authentication.md) on mount. If the current user is authenticated,`<Authenticated>` renders its child component. If the user is not authenticated, it redirects to the login page. While the authentication is being checked, `<Authenticated>` displays a loading component (empty by default).
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved

## Usage

Use it as an alternative to the `useAuthenticated()` hook when you can't use a hook, e.g. inside a `<Route element>` component:
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved

```jsx
import { Admin, CustomRoutes, Authenticated } from 'react-admin';
Expand All @@ -15,9 +19,16 @@ const App = () => (
<Admin authProvider={authProvider}>
<CustomRoutes>
<Route path="/foo" element={<Authenticated><Foo /></Authenticated>} />
<Route path="/bar" element={<Authenticated requireAuth><Bar /></Authenticated>} />
<Route path="/anoonymous" element={<Baz />} />
</CustomRoutes>
</Admin>
);
```

## Props

| Prop | Required | Type | Default | Description |
|-------------| ---------|-------------|---------|-------------------------------------------------------------------------------------|
| `children` | Required | `ReactNode` | | The component to render if the user is authenticated. |
| `authParams`| | `any` | `{}` | An object containing the parameters to pass to the `authProvider.checkAuth()` call. |
| `loading` | | `ReactNode` | `null` | Component to display while the authentication is being checked. |
23 changes: 16 additions & 7 deletions docs/Authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,11 @@ When you add custom pages, they are accessible to anonymous users by default. To

```jsx
import { Admin, CustomRoutes, useAuthenticated } from 'react-admin';
import { Route } from 'react-router-dom';

const MyPage = () => {
useAuthenticated(); // redirects to login if not authenticated
const { isPending } = useAuthenticated(); // redirects to login if not authenticated
if (isPending) return <div>Checking auth...</div>;
return (
<div>
...
Expand All @@ -160,20 +162,27 @@ Alternatively, you can use [the `<Authenticated>` component](./Authenticated.md)

```jsx
import { Admin, CustomRoutes, Authenticated } from 'react-admin';
import { Route } from 'react-router-dom';

const MyPage = () => {
return (
const RestrictedPage = () => (
<Authenticated>
<div>
...
</div>
)
};
</Authenticated>
);

const AnonymousPage = () => (
<div>
...
</div>
);

const App = () => (
<Admin authProvider={authProvider}>
<CustomRoutes>
<Route path="/foo" element={<Authenticated><MyPage /></Authenticated>} />
<Route path="/anoonymous" element={<Baz />} />
<Route path="/restricted" element={<RestrictedPage/>} />
<Route path="/anonymous" element={<AnonymousPage />} />
</CustomRoutes>
</Admin>
);
Expand Down
9 changes: 5 additions & 4 deletions docs/useAuthState.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ title: "useAuthState"

# `useAuthState`

To avoid rendering a component, and to force waiting for the `authProvider` response, use `useAuthState()` instead of `useAuthenticated()`. It calls `authProvider.checkAuth()` on mount and returns an object with 2 properties:
To avoid rendering a component, and to force waiting for the `authProvider` response, use `useAuthState()` instead of `useAuthenticated()`. It calls `authProvider.checkAuth()` on mount and returns a state object:
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved

- `isPending`: `true` just after mount, while the `authProvider` is being called. `false` once the `authProvider` has answered.
- `authenticated`: `true` while loading. then `true` or `false` depending on the `authProvider` response.
- Loading: `{ isPending: true }`
- Authenticated: `{ isPending: false, authenticated: true }`
- Not authenticated: `{ isPending: false, authenticated: false }`

You can render different content depending on the authenticated status.
You can render different content depending on the authenticated state.

```jsx
import { useAuthState, Loading } from 'react-admin';
Expand Down
46 changes: 37 additions & 9 deletions docs/useAuthenticated.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,21 @@ title: "useAuthenticated"

# `useAuthenticated`

If you add [custom pages](./Actions.md), you may need to secure access to pages manually. That's the purpose of the `useAuthenticated()` hook, which calls the `authProvider.checkAuth()` method on mount, and redirects to the login if it returns a rejected Promise.
This hook calls the `authProvider.checkAuth()` method on mount, and redirects to login if the `authProvider` returns a rejected Promise.
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved

```jsx
React-admin uses this hook in page components (e.g., the `<Edit>` component) to ensure that the user is authenticated before rendering the page.

## Usage

If you add [custom pages](./Admin.md#adding-custom-pages), and you want to restrict access to authenticated users, use `useAuthenticated()` as follows:

```tsx
// in src/MyPage.js
import { useAuthenticated } from 'react-admin';

const MyPage = () => {
useAuthenticated(); // redirects to login if not authenticated
const { isPending } = useAuthenticated(); // redirects to login if not authenticated
if (isPending) return <div>Checking auth...</div>;
return (
<div>
...
Expand All @@ -23,13 +30,22 @@ const MyPage = () => {
export default MyPage;
```

Since `authProvider.checkAuth()` is an asynchronous function, the `useAuthenticated` hook returns an object with a `isPending` property set to `true` while the check is in progress. You can use this property to display a loading indicator until the check is complete.

If you want to render different content depending on the authenticated status, you can use [the `useAuthState` hook](./useAuthState.md) instead.

## Parameters

`useAuthenticated` accepts an options object as its only argument, with the following properties:
- `enabled`: whether it should check for an authenticated user (`true` by default)
- `params`: the parameters to pass to `checkAuth`

If you call `useAuthenticated()` with a `params` option, those parameters are passed to the `authProvider.checkAuth` call. That allows you to add authentication logic depending on the context of the call:
- `params`: the parameters to pass to `authProvider.checkAuth()`
- `logoutOnFailure`: a boolean indicating whether to call `authProvider.logout` if the check fails. Defaults to `true`.

```jsx
Additional parameters are passed as options to the `useQuery` call. That allows you to add side effects, meta parameters, retryDelay, etc.

The `params` option allows you to add authentication logic depending on the context of the call:

```tsx
const MyPage = () => {
useAuthenticated({ params: { foo: 'bar' } }); // calls authProvider.checkAuth({ foo: 'bar' })
return (
Expand All @@ -40,6 +56,18 @@ const MyPage = () => {
};
```

The `useAuthenticated` hook is optimistic: it doesn't block rendering during the `authProvider` call. In the above example, the `MyPage` component renders even before getting the response from the `authProvider`. If the call returns a rejected promise, the hook redirects to the login page, but the user may have seen the content of the `MyPage` component for a brief moment.
## Component Version

If you want to render different content depending on the authenticated status, you can use [the `useAuthState` hook](./useAuthState.md) instead.
The [`<Authenticated>`](./Authenticated.md) component wraps the `useAuthenticated` hook, renders its child if the user is authenticated, or redirects to login otherwise.

It is useful when you can't use hooks, for instance because of the rules of hooks.

```jsx
import { Authenticated } from 'react-admin';

const MyAuthenticatedPage = () => (
<Authenticated>
<MyPage />
</Authenticated>
);
```
20 changes: 17 additions & 3 deletions packages/ra-core/src/auth/Authenticated.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,21 @@ import { TestMemoryRouter } from '../routing';
describe('<Authenticated>', () => {
const Foo = () => <div>Foo</div>;

it('should render its child by default', async () => {
it('should not render its child while loading', async () => {
const authProvider = {
checkAuth: new Promise(() => {}),
} as any;

render(
<CoreAdminContext authProvider={authProvider}>
<Authenticated loading={<div>Loading</div>}>
<Foo />
</Authenticated>
</CoreAdminContext>
);
await screen.findByText('Loading');
});
it('should render its child when authenticated', async () => {
const authProvider = {
login: () => Promise.reject('bad method'),
logout: () => Promise.reject('bad method'),
Expand All @@ -30,11 +44,11 @@ describe('<Authenticated>', () => {
</Authenticated>
</CoreAdminContext>
);
expect(screen.queryByText('Foo')).not.toBeNull();
await screen.findByText('Foo');
expect(reset).toHaveBeenCalledTimes(0);
});

it('should logout, redirect to login and show a notification after a tick if the auth fails', async () => {
it('should logout, redirect to login and show a notification if the auth fails', async () => {
const authProvider = {
login: jest.fn().mockResolvedValue(''),
logout: jest.fn().mockResolvedValue(''),
Expand Down
57 changes: 26 additions & 31 deletions packages/ra-core/src/auth/Authenticated.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import { ReactNode } from 'react';

import useAuthState from './useAuthState';
import { useAuthenticated } from './useAuthenticated';

/**
* Restrict access to children to authenticated users.
Expand All @@ -10,51 +10,46 @@ import useAuthState from './useAuthState';
* Use it to decorate your custom page components to require
* authentication.
*
* By default this component is optimistic: it does not block
* rendering children when checking authentication, but this mode
* can be turned off by setting `requireAuth` to true.
*
* You can set additional `authParams` at will if your authProvider
* requires it.
*
* @see useAuthState
*
* @example
* import { Admin, CustomRoutes, Authenticated } from 'react-admin';
* import { Admin, CustomRoutes, Authenticated } from 'react-admin';
*
* const customRoutes = [
* <Route
* path="/foo"
* element={
* <Authenticated authParams={{ foo: 'bar' }}>
* <Foo />
* </Authenticated>
* }
* />
* ];
* const App = () => (
* <Admin>
* <CustomRoutes>{customRoutes}</CustomRoutes>
* </Admin>
* );
* const customRoutes = [
* <Route
* path="/foo"
* element={
* <Authenticated authParams={{ foo: 'bar' }}>
* <Foo />
* </Authenticated>
* }
* />
* ];
* const App = () => (
* <Admin>
* <CustomRoutes>{customRoutes}</CustomRoutes>
* </Admin>
* );
*/
export const Authenticated = (props: AuthenticatedProps) => {
const { authParams, children, requireAuth = false } = props;
const { authParams, loading = null, children } = props;

// this hook will log out if the authProvider doesn't validate that the user is authenticated
const { isPending, authenticated } = useAuthState(authParams, true);
// this hook will redirect to login if the user is not authenticated
const { isPending } = useAuthenticated({ params: authParams });

// in pessimistic mode don't render the children until authenticated
if ((requireAuth && isPending) || !authenticated) {
return null;
if (isPending) {
return loading;
}

// render the children in optimistic rendering or after authenticated
return <>{children}</>;
};

export interface AuthenticatedProps {
children: ReactNode;
authParams?: object;
loading?: ReactNode;
/**
* @deprecated Authenticated now never renders children when not authenticated.
*/
requireAuth?: boolean;
}
11 changes: 8 additions & 3 deletions packages/ra-core/src/auth/WithPermissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,14 @@ const WithPermissions = (props: WithPermissionsProps) => {
'You should only use one of the `component`, `render` and `children` props in <WithPermissions>'
);

useAuthenticated(authParams);
const { permissions } = usePermissions(authParams);
// render even though the usePermissions() call isn't finished (optimistic rendering)
const { isPending: isAuthenticationPending } = useAuthenticated(authParams);
const { permissions, isPending } = usePermissions(authParams, {
enabled: !isAuthenticationPending,
});
if (isPending) {
return null;
}

if (component) {
return createElement(component, { permissions, ...rest });
}
Expand Down
30 changes: 17 additions & 13 deletions packages/ra-core/src/auth/useAuthenticated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,23 @@ import useAuthState from './useAuthState';
* requires it.
*
* @example
* import { Admin, CustomRoutes, useAuthenticated } from 'react-admin';
* const FooPage = () => {
* useAuthenticated();
* return <Foo />;
* }
* const customRoutes = [
* <Route path="/foo" element={<FooPage />} />
* ];
* const App = () => (
* <Admin>
* <CustomRoutes>{customRoutes}</CustomRoutes>
* </Admin>
* );
* import { Admin, CustomRoutes, useAuthenticated } from 'react-admin';
*
* const FooPage = () => {
* const { isPending } = useAuthenticated();
* if (isPending) return null;
* return <Foo />;
* }
*
* const customRoutes = [
* <Route path="/foo" element={<FooPage />} />
* ];
*
* const App = () => (
* <Admin>
* <CustomRoutes>{customRoutes}</CustomRoutes>
* </Admin>
* );
*/
export const useAuthenticated = <ParamsType = any>({
params,
Expand Down
7 changes: 5 additions & 2 deletions packages/ra-ui-materialui/src/layout/NotFound.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ import { styled } from '@mui/material/styles';
import Button from '@mui/material/Button';
import HotTub from '@mui/icons-material/HotTub';
import History from '@mui/icons-material/History';

import { useAuthenticated, useDefaultTitle, useTranslate } from 'ra-core';

import { Title } from './Title';
import { Loading } from './Loading';

export const NotFound = props => {
const { className, ...rest } = props;

const translate = useTranslate();
useAuthenticated();
const { isPending } = useAuthenticated();
const title = useDefaultTitle();

if (isPending) return <Loading />;
return (
<Root className={className} {...sanitizeRestProps(rest)}>
<Title defaultTitle={title} />
Expand Down
Loading