Skip to content

Commit

Permalink
[PUI] Session authentication (#6970)
Browse files Browse the repository at this point in the history
* Adjust backend cookie settings

* Allow CORS requests to /accounts/

* Refactor frontend code

- Remove API token functions
- Simplify cookie approach
- Add isLoggedIn method

* Adjust REST_AUTH settings

* Cleanup auth functions in auth.tsx

* Adjust CSRF_COOKIE_SAMESITE value

* Fix login request

* Prevent session auth on login view

- Existing (invalid) session token causes 403

* Refactor ApiImage

- Point to the right host
- Simplify code
- Now we use session cookies, so it *Just Works*

* Fix download for attachment table

- Now works with remote host

* Cleanup settings.py

* Refactor login / logout notifications

* Update API version

* Update src/frontend/src/components/items/AttachmentLink.tsx

Co-authored-by: Lukas <[email protected]>

* fix assert url

* Remove comment

* Add explicit page to logout user

* Change tests to first logout

* Prune dead code

* Adjust tests

* Cleanup

* Direct to login view

* Trying something

* Update CUI test

* Fix basic tests

* Refactoring

* Fix basic checks

* Fix for PUI command tests

* More test updates

* Add speciifc test for quick login

* More cleanup of playwright tests

* Add some missing icons

* Fix typo

* Ignore coverage report for playwright test

* Remove coveralls upload task

---------

Co-authored-by: Lukas <[email protected]>
Co-authored-by: Matthias Mair <[email protected]>
  • Loading branch information
3 people authored Apr 17, 2024
1 parent d24219f commit 0ba7f7e
Show file tree
Hide file tree
Showing 30 changed files with 342 additions and 360 deletions.
9 changes: 0 additions & 9 deletions .github/workflows/qc_checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -544,15 +544,6 @@ jobs:
- name: Report coverage
if: always()
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
- name: Upload Coverage Report to Coveralls
if: always()
uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949 # [email protected]
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
flag-name: pui
git-commit: ${{ github.sha }}
git-branch: ${{ github.ref }}
parallel: true
- name: Upload coverage reports to Codecov
uses: codecov/[email protected]
if: always()
Expand Down
8 changes: 6 additions & 2 deletions src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 187
INVENTREE_API_VERSION = 188
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """
v187 - 2024-03-10 : https://github.com/inventree/InvenTree/pull/6985
v188 - 2024-04-16 : https://github.com/inventree/InvenTree/pull/6970
- Adds session authentication support for the API
- Improvements for login / logout endpoints for better support of React web interface
v187 - 2024-04-10 : https://github.com/inventree/InvenTree/pull/6985
- Allow Part list endpoint to be sorted by pricing_min and pricing_max values
- Allow BomItem list endpoint to be sorted by pricing_min and pricing_max values
- Allow InternalPrice and SalePrice endpoints to be sorted by quantity
Expand Down
18 changes: 17 additions & 1 deletion src/backend/InvenTree/InvenTree/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,10 +492,18 @@
'rest_framework.renderers.BrowsableAPIRenderer'
)

# dj-rest-auth
# JWT switch
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
REST_USE_JWT = USE_JWT

# dj-rest-auth
REST_AUTH = {
'SESSION_LOGIN': True,
'TOKEN_MODEL': 'users.models.ApiToken',
'TOKEN_CREATOR': 'users.models.default_create_token',
'USE_JWT': USE_JWT,
}

OLD_PASSWORD_FIELD_ENABLED = True
REST_AUTH_REGISTER_SERIALIZERS = {
'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'
Expand All @@ -510,6 +518,7 @@
)
INSTALLED_APPS.append('rest_framework_simplejwt')


# WSGI default setting
WSGI_APPLICATION = 'InvenTree.wsgi.application'

Expand Down Expand Up @@ -1092,6 +1101,13 @@
)
sys.exit(-1)

# Additional CSRF settings
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'

USE_X_FORWARDED_HOST = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_HOST',
config_key='use_x_forwarded_host',
Expand Down
1 change: 1 addition & 0 deletions src/backend/InvenTree/InvenTree/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
SocialAccountDisconnectView.as_view(),
name='social_account_disconnect',
),
path('login/', users.api.Login.as_view(), name='api-login'),
path('logout/', users.api.Logout.as_view(), name='api-logout'),
path(
'login-redirect/',
Expand Down
16 changes: 15 additions & 1 deletion src/backend/InvenTree/users/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
from django.urls import include, path, re_path
from django.views.generic.base import RedirectView

from dj_rest_auth.views import LogoutView
from dj_rest_auth.views import LoginView, LogoutView
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
from rest_framework import exceptions, permissions
from rest_framework.authentication import BasicAuthentication
from rest_framework.decorators import authentication_classes
from rest_framework.response import Response
from rest_framework.views import APIView

Expand Down Expand Up @@ -205,6 +207,18 @@ class GroupList(ListCreateAPI):
ordering_fields = ['name']


@authentication_classes([BasicAuthentication])
@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged in')}
)
)
class Login(LoginView):
"""API view for logging in via API."""

...


@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged out')}
Expand Down
11 changes: 11 additions & 0 deletions src/backend/InvenTree/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ def default_token_expiry():
return InvenTree.helpers.current_date() + datetime.timedelta(days=365)


def default_create_token(token_model, user, serializer):
"""Generate a default value for the token."""
token = token_model.objects.filter(user=user, name='', revoked=False)

if token.exists():
return token.first()

else:
return token_model.objects.create(user=user, name='')


class ApiToken(AuthToken, InvenTree.models.MetadataMixin):
"""Extends the default token model provided by djangorestframework.authtoken.
Expand Down
24 changes: 4 additions & 20 deletions src/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,24 @@
import { QueryClient } from '@tanstack/react-query';
import axios from 'axios';

import { getCsrfCookie } from './functions/auth';
import { useLocalState } from './states/LocalState';
import { useSessionState } from './states/SessionState';

// Global API instance
export const api = axios.create({});

/*
* Setup default settings for the Axios API instance.
*
* This includes:
* - Base URL
* - Authorization token (if available)
* - CSRF token (if available)
*/
export function setApiDefaults() {
const host = useLocalState.getState().host;
const token = useSessionState.getState().token;

api.defaults.baseURL = host;
api.defaults.timeout = 2500;
api.defaults.headers.common['Authorization'] = token
? `Token ${token}`
: undefined;

if (!!getCsrfCookie()) {
api.defaults.withCredentials = true;
api.defaults.xsrfCookieName = 'csrftoken';
api.defaults.xsrfHeaderName = 'X-CSRFToken';
} else {
api.defaults.withCredentials = false;
api.defaults.xsrfCookieName = undefined;
api.defaults.xsrfHeaderName = undefined;
}
api.defaults.withCredentials = true;
api.defaults.withXSRFToken = true;
api.defaults.xsrfCookieName = 'csrftoken';
api.defaults.xsrfHeaderName = 'X-CSRFToken';
}

export const queryClient = new QueryClient();
43 changes: 17 additions & 26 deletions src/frontend/src/components/forms/AuthenticationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,14 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { doBasicLogin, doSimpleLogin } from '../../functions/auth';
import { doBasicLogin, doSimpleLogin, isLoggedIn } from '../../functions/auth';
import { showLoginNotification } from '../../functions/notifications';
import { apiUrl, useServerApiState } from '../../states/ApiState';
import { useSessionState } from '../../states/SessionState';
import { SsoButton } from '../buttons/SSOButton';

export function AuthenticationForm() {
Expand All @@ -46,19 +44,18 @@ export function AuthenticationForm() {
).then(() => {
setIsLoggingIn(false);

if (useSessionState.getState().hasToken()) {
notifications.show({
if (isLoggedIn()) {
showLoginNotification({
title: t`Login successful`,
message: t`Welcome back!`,
color: 'green',
icon: <IconCheck size="1rem" />
message: t`Logged in successfully`
});

navigate(location?.state?.redirectFrom ?? '/home');
} else {
notifications.show({
showLoginNotification({
title: t`Login failed`,
message: t`Check your input and try again.`,
color: 'red'
success: false
});
}
});
Expand All @@ -67,18 +64,15 @@ export function AuthenticationForm() {
setIsLoggingIn(false);

if (ret?.status === 'ok') {
notifications.show({
showLoginNotification({
title: t`Mail delivery successful`,
message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`,
color: 'green',
icon: <IconCheck size="1rem" />,
autoClose: false
message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`
});
} else {
notifications.show({
title: t`Input error`,
showLoginNotification({
title: t`Mail delivery failed`,
message: t`Check your input and try again.`,
color: 'red'
success: false
});
}
});
Expand Down Expand Up @@ -193,11 +187,9 @@ export function RegistrationForm() {
.then((ret) => {
if (ret?.status === 204) {
setIsRegistering(false);
notifications.show({
showLoginNotification({
title: t`Registration successful`,
message: t`Please confirm your email address to complete the registration`,
color: 'green',
icon: <IconCheck size="1rem" />
message: t`Please confirm your email address to complete the registration`
});
navigate('/home');
}
Expand All @@ -212,11 +204,10 @@ export function RegistrationForm() {
if (err.response?.data?.non_field_errors) {
err_msg = err.response.data.non_field_errors;
}
notifications.show({
showLoginNotification({
title: t`Input error`,
message: t`Check your input and try again. ` + err_msg,
color: 'red',
autoClose: 30000
success: false
});
}
});
Expand Down
62 changes: 9 additions & 53 deletions src/frontend/src/components/images/ApiImage.tsx
Original file line number Diff line number Diff line change
@@ -1,71 +1,27 @@
/**
* Component for loading an image from the InvenTree server,
* using the API's token authentication.
* Component for loading an image from the InvenTree server
*
* Image caching is handled automagically by the browsers cache
*/
import { Image, ImageProps, Skeleton, Stack } from '@mantine/core';
import { useId } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useMemo } from 'react';

import { api } from '../../App';
import { useLocalState } from '../../states/LocalState';

/**
* Construct an image container which will load and display the image
*/
export function ApiImage(props: ImageProps) {
const [image, setImage] = useState<string>('');
const { host } = useLocalState.getState();

const [authorized, setAuthorized] = useState<boolean>(true);

const queryKey = useId();

const _imgQuery = useQuery({
queryKey: ['image', queryKey, props.src],
enabled:
authorized &&
props.src != undefined &&
props.src != null &&
props.src != '',
queryFn: async () => {
if (!props.src) {
return null;
}
return api
.get(props.src, {
responseType: 'blob'
})
.then((response) => {
switch (response.status) {
case 200:
let img = new Blob([response.data], {
type: response.headers['content-type']
});
let url = URL.createObjectURL(img);
setImage(url);
break;
default:
// User is not authorized to view this image, or the image is not available
setImage('');
setAuthorized(false);
break;
}

return response;
})
.catch((_error) => {
return null;
});
},
refetchOnMount: true,
refetchOnWindowFocus: false
});
const imageUrl = useMemo(() => {
return `${host}${props.src}`;
}, [host, props.src]);

return (
<Stack>
{image && image.length > 0 ? (
<Image {...props} src={image} withPlaceholder fit="contain" />
{imageUrl ? (
<Image {...props} src={imageUrl} withPlaceholder fit="contain" />
) : (
<Skeleton
height={props?.height ?? props.width}
Expand Down
16 changes: 14 additions & 2 deletions src/frontend/src/components/items/AttachmentLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
IconFileTypeXls,
IconFileTypeZip
} from '@tabler/icons-react';
import { ReactNode } from 'react';
import { ReactNode, useMemo } from 'react';

import { useLocalState } from '../../states/LocalState';

/**
* Return an icon based on the provided filename
Expand Down Expand Up @@ -58,10 +60,20 @@ export function AttachmentLink({
}): ReactNode {
let text = external ? attachment : attachment.split('/').pop();

const host = useLocalState((s) => s.host);

const url = useMemo(() => {
if (external) {
return attachment;
}

return `${host}${attachment}`;
}, [host, attachment, external]);

return (
<Group position="left" spacing="sm">
{external ? <IconLink /> : attachmentIcon(attachment)}
<Anchor href={attachment} target="_blank" rel="noopener noreferrer">
<Anchor href={url} target="_blank" rel="noopener noreferrer">
{text}
</Anchor>
</Group>
Expand Down
Loading

0 comments on commit 0ba7f7e

Please sign in to comment.