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

[PUI] Session authentication #6970

Merged
merged 40 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
28b90c9
Adjust backend cookie settings
SchrodingersGat Apr 7, 2024
d481c8b
Allow CORS requests to /accounts/
SchrodingersGat Apr 7, 2024
e03ed83
Refactor frontend code
SchrodingersGat Apr 7, 2024
d2671dd
Adjust REST_AUTH settings
SchrodingersGat Apr 7, 2024
fe369b2
Cleanup auth functions in auth.tsx
SchrodingersGat Apr 7, 2024
c9f0c7f
Adjust CSRF_COOKIE_SAMESITE value
SchrodingersGat Apr 7, 2024
e6749ab
Fix login request
SchrodingersGat Apr 7, 2024
738a623
Prevent session auth on login view
SchrodingersGat Apr 7, 2024
3e66385
Refactor ApiImage
SchrodingersGat Apr 7, 2024
d62151b
Fix download for attachment table
SchrodingersGat Apr 7, 2024
0218418
Cleanup settings.py
SchrodingersGat Apr 7, 2024
12c95f0
Refactor login / logout notifications
SchrodingersGat Apr 7, 2024
733eb9f
Update API version
SchrodingersGat Apr 7, 2024
f6ea425
Update src/frontend/src/components/items/AttachmentLink.tsx
SchrodingersGat Apr 7, 2024
f722475
Merge remote-tracking branch 'origin/master' into session-auth
SchrodingersGat Apr 10, 2024
9176087
fix assert url
matmair Apr 10, 2024
85ad4ba
Merge branch 'master' into session-auth
SchrodingersGat Apr 13, 2024
68d098d
Remove comment
SchrodingersGat Apr 13, 2024
698bfa5
Merge remote-tracking branch 'origin/master' into session-auth
SchrodingersGat Apr 14, 2024
e60a007
Merge branch 'master' into session-auth
SchrodingersGat Apr 16, 2024
2614e73
Add explicit page to logout user
SchrodingersGat Apr 16, 2024
360cd5e
Change tests to first logout
SchrodingersGat Apr 16, 2024
542b77f
Prune dead code
SchrodingersGat Apr 16, 2024
295cb74
Adjust tests
SchrodingersGat Apr 16, 2024
32ea476
Cleanup
SchrodingersGat Apr 16, 2024
2f857cb
Direct to login view
SchrodingersGat Apr 16, 2024
b55f454
Trying something
SchrodingersGat Apr 16, 2024
dd699a4
Update CUI test
SchrodingersGat Apr 17, 2024
d8422c0
Fix basic tests
SchrodingersGat Apr 17, 2024
63fafe9
Refactoring
SchrodingersGat Apr 17, 2024
c416df5
Fix basic checks
SchrodingersGat Apr 17, 2024
c78028f
Fix for PUI command tests
SchrodingersGat Apr 17, 2024
5b1d3af
More test updates
SchrodingersGat Apr 17, 2024
b52ff8c
Merge remote-tracking branch 'origin/master' into session-auth
SchrodingersGat Apr 17, 2024
281e03e
Add speciifc test for quick login
SchrodingersGat Apr 17, 2024
fe0b5dc
More cleanup of playwright tests
SchrodingersGat Apr 17, 2024
8bef572
Add some missing icons
SchrodingersGat Apr 17, 2024
5147158
Fix typo
SchrodingersGat Apr 17, 2024
db9b46d
Ignore coverage report for playwright test
SchrodingersGat Apr 17, 2024
b8cfe8c
Remove coveralls upload task
SchrodingersGat Apr 17, 2024
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
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 186
INVENTREE_API_VERSION = 187
"""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-04-07 : https://github.com/inventree/InvenTree/pull/6970
- Improvements for login / logout endpoints for better support of React web interface

v186 - 2024-03-26 : https://github.com/inventree/InvenTree/pull/6855
- Adds license information to the API

Expand Down
16 changes: 15 additions & 1 deletion src/backend/InvenTree/InvenTree/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,10 +469,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 @@ -487,6 +495,7 @@
)
INSTALLED_APPS.append('rest_framework_simplejwt')


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

Expand Down Expand Up @@ -1069,6 +1078,11 @@
)
sys.exit(-1)

# Additional CSRF settings
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
CSRF_COOKIE_NAME = 'csrftoken'
CSRF_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();
41 changes: 17 additions & 24 deletions src/frontend/src/components/forms/AuthenticationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ 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 +46,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 +66,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 +189,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 +206,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.getState();
SchrodingersGat marked this conversation as resolved.
Show resolved Hide resolved

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
6 changes: 2 additions & 4 deletions src/frontend/src/components/nav/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { Container, Flex, Space } from '@mantine/core';
import { Navigate, Outlet, useLocation } from 'react-router-dom';

import { isLoggedIn } from '../../functions/auth';
import { InvenTreeStyle } from '../../globalStyle';
import { useSessionState } from '../../states/SessionState';
import { Footer } from './Footer';
import { Header } from './Header';

export const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
const [token] = useSessionState((state) => [state.token]);

const location = useLocation();

if (!token) {
if (!isLoggedIn()) {
return (
<Navigate to="/logged-in" state={{ redirectFrom: location.pathname }} />
);
Expand Down
Loading
Loading