diff --git a/CHANGELOG.md b/CHANGELOG.md index eee6de14..e10a8c9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to - ✨(frontend) integrate configurable Waffle #1795 - ✨ Import of documents #1609 - 🚨(CI) gives warning if theme not updated #1811 +- ✨(auth) add silent login #1690 - 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825 ### Changed diff --git a/env.d/development/common b/env.d/development/common index 7f8b1311..fad2bac5 100644 --- a/env.d/development/common +++ b/env.d/development/common @@ -48,7 +48,7 @@ LOGIN_REDIRECT_URL=http://localhost:3000 LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000 LOGOUT_REDIRECT_URL=http://localhost:3000 -OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"] +OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000" OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} # Store OIDC tokens in the session. Needed by search/ endpoint. diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 75101603..6b6c4239 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -549,6 +549,16 @@ class Base(Configuration): SESSION_COOKIE_NAME = "docs_sessionid" # OIDC - Authorization Code Flow + OIDC_AUTHENTICATE_CLASS = values.Value( + "lasuite.oidc_login.views.OIDCAuthenticationRequestView", + environ_name="OIDC_AUTHENTICATE_CLASS", + environ_prefix=None, + ) + OIDC_CALLBACK_CLASS = values.Value( + "lasuite.oidc_login.views.OIDCAuthenticationCallbackView", + environ_name="OIDC_CALLBACK_CLASS", + environ_prefix=None, + ) OIDC_CREATE_USER = values.BooleanValue( default=True, environ_name="OIDC_CREATE_USER", diff --git a/src/frontend/apps/e2e/__tests__/app-impress/login.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/login.spec.ts new file mode 100644 index 00000000..fc72e631 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/login.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Login: Not logged', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('It tries silent login', async ({ page }) => { + const silentLoginRequest = page.waitForRequest((request) => + request.url().includes('/api/v1.0/authenticate/?silent=true'), + ); + + await page.goto('/'); + + await silentLoginRequest; + expect(silentLoginRequest).toBeDefined(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/auth/__tests__/utils.test.tsx b/src/frontend/apps/impress/src/features/auth/__tests__/utils.test.tsx index 1c9bff1b..b92cc0ab 100644 --- a/src/frontend/apps/impress/src/features/auth/__tests__/utils.test.tsx +++ b/src/frontend/apps/impress/src/features/auth/__tests__/utils.test.tsx @@ -1,33 +1,40 @@ import fetchMock from 'fetch-mock'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { gotoLogout } from '../utils'; +import { SILENT_LOGIN_RETRY } from '../conf'; +import { gotoLogout, gotoSilentLogin } from '../utils'; // Mock the Crisp service vi.mock('@/services/Crisp', () => ({ terminateCrispSession: vi.fn(), })); +// Add mock on window.location.replace +const mockReplace = vi.fn(); +Object.defineProperty(window, 'location', { + value: { + ...window.location, + replace: mockReplace, + href: 'http://test.jest/', + }, + writable: true, + configurable: true, +}); + +const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + describe('utils', () => { afterEach(() => { vi.clearAllMocks(); fetchMock.restore(); + mockReplace.mockClear(); + setItemSpy.mockClear(); + localStorage.clear(); }); it('checks support session is terminated when logout', async () => { const { terminateCrispSession } = await import('@/services/Crisp'); - // Mock window.location.replace - const mockReplace = vi.fn(); - Object.defineProperty(window, 'location', { - value: { - ...window.location, - replace: mockReplace, - }, - writable: true, - configurable: true, - }); - gotoLogout(); expect(terminateCrispSession).toHaveBeenCalled(); @@ -35,4 +42,13 @@ describe('utils', () => { 'http://test.jest/api/v1.0/logout/', ); }); + + it('checks the gotoSilentLogin', async () => { + gotoSilentLogin(); + + expect(mockReplace).toHaveBeenCalledWith( + 'http://test.jest/api/v1.0/authenticate/?silent=true&next=http%3A%2F%2Ftest.jest%2F', + ); + expect(setItemSpy).toHaveBeenCalledWith(SILENT_LOGIN_RETRY, 'true'); + }); }); diff --git a/src/frontend/apps/impress/src/features/auth/components/Auth.tsx b/src/frontend/apps/impress/src/features/auth/components/Auth.tsx index ea079d1f..a0fa349d 100644 --- a/src/frontend/apps/impress/src/features/auth/components/Auth.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/Auth.tsx @@ -1,19 +1,37 @@ import { useRouter } from 'next/router'; -import { PropsWithChildren, useEffect, useState } from 'react'; +import { PropsWithChildren, useEffect, useMemo, useState } from 'react'; import { Loading } from '@/components'; import { useConfig } from '@/core'; import { HOME_URL } from '../conf'; import { useAuth } from '../hooks'; -import { getAuthUrl, gotoLogin } from '../utils'; +import { + getAuthUrl, + gotoLogin, + gotoSilentLogin, + hasTrySilent, + resetSilent, +} from '../utils'; export const Auth = ({ children }: PropsWithChildren) => { - const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } = - useAuth(); - const { replace, pathname } = useRouter(); - const { data: config } = useConfig(); + const { + isLoading: isAuthLoading, + pathAllowed, + isFetchedAfterMount, + authenticated, + fetchStatus, + } = useAuth(); + const isLoading = fetchStatus !== 'idle' || isAuthLoading; const [isRedirecting, setIsRedirecting] = useState(false); + const { data: config } = useConfig(); + const shouldTrySilentLogin = useMemo( + () => !authenticated && !hasTrySilent() && !isLoading && !isRedirecting, + [authenticated, isLoading, isRedirecting], + ); + const shouldTryLogin = + !authenticated && !isLoading && !isRedirecting && !pathAllowed; + const { replace, pathname } = useRouter(); /** * If the user is authenticated and initially wanted to access a specific page, redirect him to that page now. @@ -23,6 +41,10 @@ export const Auth = ({ children }: PropsWithChildren) => { return; } + if (hasTrySilent()) { + resetSilent(); + } + const authUrl = getAuthUrl(); if (authUrl) { setIsRedirecting(true); @@ -34,7 +56,13 @@ export const Auth = ({ children }: PropsWithChildren) => { * If the user is not authenticated and not on a allowed pages */ useEffect(() => { - if (isLoading || authenticated || pathAllowed || isRedirecting) { + if (shouldTrySilentLogin) { + setIsRedirecting(true); + gotoSilentLogin(); + return; + } + + if (!shouldTryLogin) { return; } @@ -56,19 +84,17 @@ export const Auth = ({ children }: PropsWithChildren) => { setIsRedirecting(true); gotoLogin(); }, [ - authenticated, - pathAllowed, config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED, - replace, - isLoading, - isRedirecting, pathname, + shouldTryLogin, + shouldTrySilentLogin, ]); const shouldShowLoader = (isLoading && !isFetchedAfterMount) || isRedirecting || - (!authenticated && !pathAllowed); + (!authenticated && !pathAllowed) || + shouldTrySilentLogin; if (shouldShowLoader) { return ; diff --git a/src/frontend/apps/impress/src/features/auth/conf.ts b/src/frontend/apps/impress/src/features/auth/conf.ts index 4e58db0f..5feae77c 100644 --- a/src/frontend/apps/impress/src/features/auth/conf.ts +++ b/src/frontend/apps/impress/src/features/auth/conf.ts @@ -4,3 +4,4 @@ export const HOME_URL = '/home/'; export const LOGIN_URL = `${baseApiUrl()}authenticate/`; export const LOGOUT_URL = `${baseApiUrl()}logout/`; export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth'; +export const SILENT_LOGIN_RETRY = 'silent-login-retry'; diff --git a/src/frontend/apps/impress/src/features/auth/utils.ts b/src/frontend/apps/impress/src/features/auth/utils.ts index 2e765b24..cbb05c29 100644 --- a/src/frontend/apps/impress/src/features/auth/utils.ts +++ b/src/frontend/apps/impress/src/features/auth/utils.ts @@ -1,19 +1,21 @@ import { terminateCrispSession } from '@/services/Crisp'; +import { safeLocalStorage } from '@/utils/storages'; import { HOME_URL, LOGIN_URL, LOGOUT_URL, PATH_AUTH_LOCAL_STORAGE, + SILENT_LOGIN_RETRY, } from './conf'; /** * Get the stored auth URL from local storage */ export const getAuthUrl = () => { - const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE); + const path_auth = safeLocalStorage.getItem(PATH_AUTH_LOCAL_STORAGE); if (path_auth) { - localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE); + safeLocalStorage.removeItem(PATH_AUTH_LOCAL_STORAGE); return path_auth; } }; @@ -27,7 +29,7 @@ export const setAuthUrl = () => { window.location.pathname !== '/' && window.location.pathname !== `${HOME_URL}/` ) { - localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.href); + safeLocalStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.href); } }; @@ -39,6 +41,29 @@ export const gotoLogin = (withRedirect = true) => { window.location.replace(LOGIN_URL); }; +export const gotoSilentLogin = () => { + // Already tried silent login, dont try again + if (!hasTrySilent()) { + const params = new URLSearchParams({ + silent: 'true', + next: window.location.href, + }); + + safeLocalStorage.setItem(SILENT_LOGIN_RETRY, 'true'); + + const REDIRECT = `${LOGIN_URL}?${params.toString()}`; + window.location.replace(REDIRECT); + } +}; + +export const hasTrySilent = () => { + return !!safeLocalStorage.getItem(SILENT_LOGIN_RETRY); +}; + +export const resetSilent = () => { + safeLocalStorage.removeItem(SILENT_LOGIN_RETRY); +}; + export const gotoLogout = () => { terminateCrispSession(); window.location.replace(LOGOUT_URL); diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 3c077b7f..f0956296 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -171,19 +171,25 @@ const DocPage = ({ id }: DocProps) => { }); }, [addTask, doc?.id, queryClient]); - if (isError && error) { - if ([404, 401].includes(error.status)) { - let replacePath = `/${error.status}`; + useEffect(() => { + if (!isError || !error?.status || ![404, 401].includes(error.status)) { + return; + } - if (error.status === 401) { - if (authenticated) { - queryClient.setQueryData([KEY_AUTH], null); - } - setAuthUrl(); + let replacePath = `/${error.status}`; + + if (error.status === 401) { + if (authenticated) { + queryClient.setQueryData([KEY_AUTH], null); } + setAuthUrl(); + } - void replace(replacePath); + void replace(replacePath); + }, [isError, error?.status, replace, authenticated, queryClient]); + if (isError && error?.status) { + if ([404, 401].includes(error.status)) { return ; }