diff --git a/web/src/components/InformationIcon.test.tsx b/web/src/components/InformationIcon.test.tsx new file mode 100644 index 00000000..d1fa2b5c --- /dev/null +++ b/web/src/components/InformationIcon.test.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { mount } from "enzyme"; +import InformationIcon from "./InformationIcon"; + +it('renders without crashing', () => { + mount(); +}); \ No newline at end of file diff --git a/web/src/components/InformationIcon.tsx b/web/src/components/InformationIcon.tsx new file mode 100644 index 00000000..9f5494c3 --- /dev/null +++ b/web/src/components/InformationIcon.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; + +export interface Props { } + +export default function (props: Props) { + return ( + + ) +} \ No newline at end of file diff --git a/web/src/hooks/UserPreferences.ts b/web/src/hooks/UserInfo.ts similarity index 74% rename from web/src/hooks/UserPreferences.ts rename to web/src/hooks/UserInfo.ts index cd24227a..ad00a086 100644 --- a/web/src/hooks/UserPreferences.ts +++ b/web/src/hooks/UserInfo.ts @@ -1,5 +1,5 @@ import { getUserPreferences } from "../services/UserPreferences"; -import { useRemoteCall } from "../hooks/RemoteCall"; +import { useRemoteCall } from "./RemoteCall"; export function useUserPreferences() { return useRemoteCall(getUserPreferences, []); diff --git a/web/src/models/UserPreferences.ts b/web/src/models/UserInfo.ts similarity index 53% rename from web/src/models/UserPreferences.ts rename to web/src/models/UserInfo.ts index 0743e31c..3e10203a 100644 --- a/web/src/models/UserPreferences.ts +++ b/web/src/models/UserInfo.ts @@ -1,5 +1,7 @@ import { SecondFactorMethod } from "./Methods"; -export interface UserPreferences { +export interface UserInfo { method: SecondFactorMethod; + has_u2f: boolean; + has_totp: boolean; } diff --git a/web/src/services/UserPreferences.ts b/web/src/services/UserPreferences.ts index cd2b155c..c5be1057 100644 --- a/web/src/services/UserPreferences.ts +++ b/web/src/services/UserPreferences.ts @@ -1,11 +1,17 @@ import { Get, PostWithOptionalResponse } from "./Client"; import { UserInfoPath, UserInfo2FAMethodPath } from "./Api"; import { SecondFactorMethod } from "../models/Methods"; -import { UserPreferences } from "../models/UserPreferences"; +import { UserInfo } from "../models/UserInfo"; export type Method2FA = "u2f" | "totp" | "mobile_push"; -export interface UserPreferencesPayload { +export interface UserInfoPayload { + method: Method2FA; + has_u2f: boolean; + has_totp: boolean; +} + +export interface MethodPreferencePayload { method: Method2FA; } @@ -31,12 +37,12 @@ export function toString(method: SecondFactorMethod): Method2FA { } } -export async function getUserPreferences(): Promise { - const res = await Get(UserInfoPath); - return { method: toEnum(res.method) }; +export async function getUserPreferences(): Promise { + const res = await Get(UserInfoPath); + return { ...res, method: toEnum(res.method) }; } export function setPrefered2FAMethod(method: SecondFactorMethod) { return PostWithOptionalResponse(UserInfo2FAMethodPath, - { method: toString(method) } as UserPreferencesPayload); + { method: toString(method) } as MethodPreferencePayload); } \ No newline at end of file diff --git a/web/src/views/LoginPortal/LoginPortal.tsx b/web/src/views/LoginPortal/LoginPortal.tsx index 6a0a2e19..39aded58 100644 --- a/web/src/views/LoginPortal/LoginPortal.tsx +++ b/web/src/views/LoginPortal/LoginPortal.tsx @@ -11,7 +11,7 @@ import LoadingPage from "../LoadingPage/LoadingPage"; import { AuthenticationLevel } from "../../services/State"; import { useNotifications } from "../../hooks/NotificationsContext"; import { useRedirectionURL } from "../../hooks/RedirectionURL"; -import { useUserPreferences } from "../../hooks/UserPreferences"; +import { useUserPreferences as userUserInfo } from "../../hooks/UserInfo"; import { SecondFactorMethod } from "../../models/Methods"; import { useAutheliaConfiguration } from "../../hooks/Configuration"; @@ -23,7 +23,7 @@ export default function () { const [firstFactorDisabled, setFirstFactorDisabled] = useState(true); const [state, fetchState, , fetchStateError] = useAutheliaState(); - const [preferences, fetchPreferences, , fetchPreferencesError] = useUserPreferences(); + const [userInfo, fetchUserInfo, , fetchUserInfoError] = userUserInfo(); const [configuration, fetchConfiguration, , fetchConfigurationError] = useAutheliaConfiguration(); const redirect = useCallback((url: string) => history.push(url), [history]); @@ -34,10 +34,10 @@ export default function () { // Fetch preferences and configuration when user is authenticated. useEffect(() => { if (state && state.authentication_level >= AuthenticationLevel.OneFactor) { - fetchPreferences(); + fetchUserInfo(); fetchConfiguration(); } - }, [state, fetchPreferences, fetchConfiguration]); + }, [state, fetchUserInfo, fetchConfiguration]); // Enable first factor when user is unauthenticated. useEffect(() => { @@ -62,10 +62,10 @@ export default function () { // Display an error when preferences fetching fails useEffect(() => { - if (fetchPreferencesError) { + if (fetchUserInfoError) { createErrorNotification("There was an issue retrieving user preferences"); } - }, [fetchPreferencesError, createErrorNotification]); + }, [fetchUserInfoError, createErrorNotification]); // Redirect to the correct stage if not enough authenticated useEffect(() => { @@ -77,18 +77,17 @@ export default function () { if (state.authentication_level === AuthenticationLevel.Unauthenticated) { setFirstFactorDisabled(false); redirect(`${FirstFactorRoute}${redirectionSuffix}`); - } else if (state.authentication_level >= AuthenticationLevel.OneFactor && preferences) { - console.log("redirect"); - if (preferences.method === SecondFactorMethod.U2F) { + } else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo) { + if (userInfo.method === SecondFactorMethod.U2F) { redirect(`${SecondFactorU2FRoute}${redirectionSuffix}`); - } else if (preferences.method === SecondFactorMethod.MobilePush) { + } else if (userInfo.method === SecondFactorMethod.MobilePush) { redirect(`${SecondFactorPushRoute}${redirectionSuffix}`); } else { redirect(`${SecondFactorTOTPRoute}${redirectionSuffix}`); } } } - }, [state, redirectionURL, redirect, preferences, setFirstFactorDisabled]); + }, [state, redirectionURL, redirect, userInfo, setFirstFactorDisabled]); const handleFirstFactorSuccess = async (redirectionURL: string | undefined) => { if (redirectionURL) { @@ -125,12 +124,12 @@ export default function () { - {state && preferences && configuration ? fetchPreferences()} + onMethodChanged={() => fetchUserInfo()} onAuthenticationSuccess={handleSecondFactorSuccess} /> : null} diff --git a/web/src/views/LoginPortal/SecondFactor/IconWithContext.tsx b/web/src/views/LoginPortal/SecondFactor/IconWithContext.tsx index b26bb219..577b70b8 100644 --- a/web/src/views/LoginPortal/SecondFactor/IconWithContext.tsx +++ b/web/src/views/LoginPortal/SecondFactor/IconWithContext.tsx @@ -12,9 +12,7 @@ interface IconWithContextProps { export default function (props: IconWithContextProps) { const iconSize = 64; const style = makeStyles(theme => ({ - root: { - height: iconSize + theme.spacing(6), - }, + root: {}, iconContainer: { display: "flex", flexDirection: "column", @@ -26,7 +24,6 @@ export default function (props: IconWithContextProps) { }, context: { display: "block", - height: theme.spacing(6), } }))(); diff --git a/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx b/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx index 9adaa257..0d136221 100644 --- a/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx +++ b/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx @@ -1,22 +1,50 @@ -import React, { ReactNode } from "react"; -import { makeStyles, Typography, Link } from "@material-ui/core"; +import React, { ReactNode, Fragment } from "react"; +import { makeStyles, Typography, Link, useTheme } from "@material-ui/core"; +import SuccessIcon from "../../../components/SuccessIcon"; +import InformationIcon from "../../../components/InformationIcon"; -interface MethodContainerProps { +export enum State { + ALREADY_AUTHENTICATED = 1, + NOT_REGISTERED = 2, + METHOD = 3 +} + +export interface Props { id: string; title: string; explanation: string; + state: State; children: ReactNode; onRegisterClick?: () => void; } -export default function (props: MethodContainerProps) { +export default function (props: Props) { const style = useStyles(); + + let container: ReactNode; + switch (props.state) { + case State.ALREADY_AUTHENTICATED: + container = + break; + case State.NOT_REGISTERED: + container = + break; + case State.METHOD: + container = + {props.children} + + break; + } + return (
{props.title} -
{props.children}
- {props.explanation} +
+
+ {container} +
+
{props.onRegisterClick ? ({ - icon: { - paddingTop: theme.spacing(2), - paddingBottom: theme.spacing(2), + container: { + height: "200px", }, -})); \ No newline at end of file + containerFlex: { + display: "flex", + flexWrap: "wrap", + height: "100%", + width: "100%", + alignItems: "center", + alignContent: "center", + justifyContent: "center", + } +})); + +function AlreadyAuthenticatedContainer() { + const theme = useTheme(); + return ( + +
+ Authenticated! +
+ ) +} + +function NotRegisteredContainer() { + const theme = useTheme(); + return ( + +
+ Register your first device by clicking on the link below +
+ ) +} + +interface MethodContainerProps { + explanation: string; + children: ReactNode; +} + +function MethodContainer(props: MethodContainerProps) { + const theme = useTheme(); + return ( + +
{props.children}
+ {props.explanation} +
+ ) +} \ No newline at end of file diff --git a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx index 257439a3..43c021fc 100644 --- a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx +++ b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from "react"; -import MethodContainer from "./MethodContainer"; +import MethodContainer, { State as MethodContainerState } from "./MethodContainer"; import OTPDial from "./OTPDial"; import { completeTOTPSignIn } from "../../../services/OneTimePassword"; import { useRedirectionURL } from "../../../hooks/RedirectionURL"; @@ -15,6 +15,7 @@ export enum State { export interface Props { id: string; authenticationLevel: AuthenticationLevel; + registered: boolean; onRegisterClick: () => void; onSignInError: (err: Error) => void; @@ -65,11 +66,19 @@ export default function (props: Props) { useEffect(() => { signInFunc() }, [signInFunc]); + let methodState = MethodContainerState.METHOD; + if (props.authenticationLevel === AuthenticationLevel.TwoFactor) { + methodState = MethodContainerState.ALREADY_AUTHENTICATED; + } else if (!props.registered) { + methodState = MethodContainerState.NOT_REGISTERED; + } + return ( ; } + let methodState = MethodContainerState.METHOD; + if (props.authenticationLevel === AuthenticationLevel.TwoFactor) { + methodState = MethodContainerState.ALREADY_AUTHENTICATED; + } + return ( + explanation="A notification has been sent to your smartphone" + state={methodState}>
{icon}
diff --git a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx index c989b0df..3497a243 100644 --- a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx +++ b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx @@ -17,7 +17,7 @@ import { SecondFactorPushRoute, SecondFactorU2FRoute, SecondFactorRoute } from "../../../Routes"; import { setPrefered2FAMethod } from "../../../services/UserPreferences"; -import { UserPreferences } from "../../../models/UserPreferences"; +import { UserInfo } from "../../../models/UserInfo"; import { Configuration } from "../../../models/Configuration"; import u2fApi from "u2f-api"; import { AuthenticationLevel } from "../../../services/State"; @@ -28,7 +28,7 @@ export interface Props { username: string; authenticationLevel: AuthenticationLevel; - userPreferences: UserPreferences; + userInfo: UserInfo; configuration: Configuration; onMethodChanged: (method: SecondFactorMethod) => void; @@ -109,6 +109,8 @@ export default function (props: Props) { createErrorNotification(err.message)} onSignInSuccess={props.onAuthenticationSuccess} /> @@ -117,6 +119,8 @@ export default function (props: Props) { createErrorNotification(err.message)} onSignInSuccess={props.onAuthenticationSuccess} /> diff --git a/web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx b/web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx index 7f3b4e23..23da9d5e 100644 --- a/web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx +++ b/web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState, Fragment } from "react"; -import MethodContainer from "./MethodContainer"; +import MethodContainer, { State as MethodContainerState } from "./MethodContainer"; import { makeStyles, Button, useTheme } from "@material-ui/core"; import { initiateU2FSignin, completeU2FSignin } from "../../../services/SecurityKey"; import u2fApi from "u2f-api"; @@ -8,7 +8,6 @@ import { useIsMountedRef } from "../../../hooks/Mounted"; import { useTimer } from "../../../hooks/Timer"; import LinearProgressBar from "../../../components/LinearProgressBar"; import FingerTouchIcon from "../../../components/FingerTouchIcon"; -import SuccessIcon from "../../../components/SuccessIcon"; import FailureIcon from "../../../components/FailureIcon"; import IconWithContext from "./IconWithContext"; import { CSSProperties } from "@material-ui/styles"; @@ -17,13 +16,13 @@ import { AuthenticationLevel } from "../../../services/State"; export enum State { WaitTouch = 1, SigninInProgress = 2, - Success = 3, - Failure = 4, + Failure = 3, } export interface Props { id: string; authenticationLevel: AuthenticationLevel; + registered: boolean; onRegisterClick: () => void; onSignInError: (err: Error) => void; @@ -44,7 +43,7 @@ export default function (props: Props) { const doInitiateSignIn = useCallback(async () => { // If user is already authenticated, we don't initiate sign in process. - if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) { + if (!props.registered || props.authenticationLevel >= AuthenticationLevel.TwoFactor) { return; } @@ -69,8 +68,7 @@ export default function (props: Props) { setState(State.SigninInProgress); const res = await completeU2FSignin(signResponse, redirectionURL); - setState(State.Success); - setTimeout(() => { onSignInSuccessCallback(res ? res.redirect : undefined) }, 1500); + onSignInSuccessCallback(res ? res.redirect : undefined); } catch (err) { // If the request was initiated and the user changed 2FA method in the meantime, // the process is interrupted to avoid updating state of unmounted component. @@ -79,22 +77,23 @@ export default function (props: Props) { onSignInErrorCallback(new Error("Failed to initiate security key sign in process")); setState(State.Failure); } - }, [onSignInSuccessCallback, onSignInErrorCallback, redirectionURL, mounted, triggerTimer, props.authenticationLevel]); - - // Set successful state if user is already authenticated. - useEffect(() => { - if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) { - setState(State.Success); - } - }, [props.authenticationLevel, setState]); + }, [onSignInSuccessCallback, onSignInErrorCallback, redirectionURL, mounted, triggerTimer, props.authenticationLevel, props.registered]); useEffect(() => { doInitiateSignIn() }, [doInitiateSignIn]); + let methodState = MethodContainerState.METHOD; + if (props.authenticationLevel === AuthenticationLevel.TwoFactor) { + methodState = MethodContainerState.ALREADY_AUTHENTICATED; + } else if (!props.registered) { + methodState = MethodContainerState.NOT_REGISTERED; + } + return (
@@ -134,15 +133,9 @@ function Icon(props: IconProps) { context={} className={state === State.Failure ? undefined : "hidden"} /> - const success = } - context={
Success!
} - className={state === State.Success || state === State.SigninInProgress ? undefined : "hidden"} /> - return ( {touch} - {success} {failure} )