From 1991c443baf2d66d08b188fdfe6768f1735494fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Nu=C3=B1ez?= <10672208+mind-ar@users.noreply.github.com> Date: Sun, 19 Jun 2022 09:43:19 -0300 Subject: [PATCH] feat(web): auto-redirect on appropriate authentication state changes (#3187) This PR checks the authentication state of the Authelia portal on either a focus event or 1-second timer and if a state change has occurred will redirect accordingly. Closes #3000. Co-authored-by: Amir Zarrinkafsh --- internal/suites/suite_standalone.go | 2 +- internal/suites/suite_standalone_test.go | 25 +++++++++++ web/src/hooks/PageVisibility.ts | 43 +++++++++++++++++++ .../FirstFactor/FirstFactorForm.tsx | 20 +++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 web/src/hooks/PageVisibility.ts diff --git a/internal/suites/suite_standalone.go b/internal/suites/suite_standalone.go index 000998fd4..899dca7f1 100644 --- a/internal/suites/suite_standalone.go +++ b/internal/suites/suite_standalone.go @@ -62,7 +62,7 @@ func init() { OnError: displayAutheliaLogs, OnSetupTimeout: displayAutheliaLogs, TearDown: teardown, - TestTimeout: 3 * time.Minute, + TestTimeout: 4 * time.Minute, TearDownTimeout: 2 * time.Minute, Description: `This suite is used to test Authelia in a standalone configuration with in-memory sessions and a local sqlite db stored on disk`, diff --git a/internal/suites/suite_standalone_test.go b/internal/suites/suite_standalone_test.go index fc37f2c08..6860f3a32 100644 --- a/internal/suites/suite_standalone_test.go +++ b/internal/suites/suite_standalone_test.go @@ -71,6 +71,31 @@ func (s *StandaloneWebDriverSuite) TestShouldLetUserKnowHeIsAlreadyAuthenticated s.verifyIsAuthenticatedPage(s.T(), s.Context(ctx)) } +func (s *StandaloneWebDriverSuite) TestShouldRedirectAfterOneFactorOnAnotherTab() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + targetURL := fmt.Sprintf("%s/secret.html", SingleFactorBaseURL) + page2 := s.Browser().MustPage(targetURL) + + defer func() { + cancel() + s.collectScreenshot(ctx.Err(), s.Page) + s.collectScreenshot(ctx.Err(), page2) + page2.MustClose() + }() + + // Open second tab with secret page. + page2.MustWaitLoad() + + // Switch to first, visit the login page and wait for redirection to secret page with secret displayed. + s.Page.MustActivate() + s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, targetURL) + s.verifySecretAuthorized(s.T(), s.Page) + + // Switch to second tab and wait for redirection to secret page with secret displayed. + page2.MustActivate() + s.verifySecretAuthorized(s.T(), page2.Context(ctx)) +} + func (s *StandaloneWebDriverSuite) TestShouldRedirectAlreadyAuthenticatedUser() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer func() { diff --git a/web/src/hooks/PageVisibility.ts b/web/src/hooks/PageVisibility.ts new file mode 100644 index 000000000..06948d4d0 --- /dev/null +++ b/web/src/hooks/PageVisibility.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +function getBrowserVisibilityProp() { + if (typeof document.hidden !== "undefined") { + // Opera 12.10 and Firefox 18 and later support + return "visibilitychange"; + } else if (typeof document.msHidden !== "undefined") { + return "msvisibilitychange"; + } else if (typeof document.webkitHidden !== "undefined") { + return "webkitvisibilitychange"; + } +} + +function getBrowserDocumentHiddenProp() { + if (typeof document.hidden !== "undefined") { + return "hidden"; + } else if (typeof document.msHidden !== "undefined") { + return "msHidden"; + } else if (typeof document.webkitHidden !== "undefined") { + return "webkitHidden"; + } +} + +function getIsDocumentHidden() { + return !document[getBrowserDocumentHiddenProp()]; +} + +export function usePageVisibility() { + const [isVisible, setIsVisible] = useState(getIsDocumentHidden()); + const onVisibilityChange = () => setIsVisible(getIsDocumentHidden()); + + useEffect(() => { + const visibilityChange = getBrowserVisibilityProp(); + + document.addEventListener(visibilityChange, onVisibilityChange, false); + + return () => { + document.removeEventListener(visibilityChange, onVisibilityChange); + }; + }); + + return isVisible; +} diff --git a/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx b/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx index 329c830e2..76f4e5fdc 100644 --- a/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx +++ b/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx @@ -8,10 +8,13 @@ import { useNavigate } from "react-router-dom"; import FixedTextField from "@components/FixedTextField"; import { ResetPasswordStep1Route } from "@constants/Routes"; import { useNotifications } from "@hooks/NotificationsContext"; +import { usePageVisibility } from "@hooks/PageVisibility"; import { useRedirectionURL } from "@hooks/RedirectionURL"; import { useRequestMethod } from "@hooks/RequestMethod"; +import { useAutheliaState } from "@hooks/State"; import LoginLayout from "@layouts/LoginLayout"; import { postFirstFactor } from "@services/FirstFactor"; +import { AuthenticationLevel } from "@services/State"; export interface Props { disabled: boolean; @@ -31,6 +34,7 @@ const FirstFactorForm = function (props: Props) { const redirectionURL = useRedirectionURL(); const requestMethod = useRequestMethod(); + const [state, fetchState, ,] = useAutheliaState(); const [rememberMe, setRememberMe] = useState(false); const [username, setUsername] = useState(""); const [usernameError, setUsernameError] = useState(false); @@ -40,12 +44,28 @@ const FirstFactorForm = function (props: Props) { // TODO (PR: #806, Issue: #511) potentially refactor const usernameRef = useRef() as MutableRefObject; const passwordRef = useRef() as MutableRefObject; + const visible = usePageVisibility(); const { t: translate } = useTranslation(); + useEffect(() => { const timeout = setTimeout(() => usernameRef.current.focus(), 10); return () => clearTimeout(timeout); }, [usernameRef]); + useEffect(() => { + if (visible) { + fetchState(); + } + const timer = setInterval(() => fetchState(), 1000); + return () => clearInterval(timer); + }, [visible, fetchState]); + + useEffect(() => { + if (state && state.authentication_level >= AuthenticationLevel.OneFactor) { + props.onAuthenticationSuccess(redirectionURL); + } + }, [state, redirectionURL, props]); + const disabled = props.disabled; const handleRememberMeChange = () => {