Let the user know device is not enrolled.

A message is now displayed to the user when he first sign in
in Authelia letting him know that a device must be enrolled.

Also introduce a message letting him know when he is already
authenticated.
This commit is contained in:
Clement Michaud 2019-12-07 15:40:55 +01:00 committed by Clément Michaud
parent 5942e00412
commit 5f8726fe87
12 changed files with 167 additions and 62 deletions

View File

@ -0,0 +1,7 @@
import React from 'react';
import { mount } from "enzyme";
import InformationIcon from "./InformationIcon";
it('renders without crashing', () => {
mount(<InformationIcon />);
});

View File

@ -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 (
<FontAwesomeIcon icon={faInfoCircle} size="4x" color="#5858ff" className="information-icon" />
)
}

View File

@ -1,5 +1,5 @@
import { getUserPreferences } from "../services/UserPreferences"; import { getUserPreferences } from "../services/UserPreferences";
import { useRemoteCall } from "../hooks/RemoteCall"; import { useRemoteCall } from "./RemoteCall";
export function useUserPreferences() { export function useUserPreferences() {
return useRemoteCall(getUserPreferences, []); return useRemoteCall(getUserPreferences, []);

View File

@ -1,5 +1,7 @@
import { SecondFactorMethod } from "./Methods"; import { SecondFactorMethod } from "./Methods";
export interface UserPreferences { export interface UserInfo {
method: SecondFactorMethod; method: SecondFactorMethod;
has_u2f: boolean;
has_totp: boolean;
} }

View File

@ -1,11 +1,17 @@
import { Get, PostWithOptionalResponse } from "./Client"; import { Get, PostWithOptionalResponse } from "./Client";
import { UserInfoPath, UserInfo2FAMethodPath } from "./Api"; import { UserInfoPath, UserInfo2FAMethodPath } from "./Api";
import { SecondFactorMethod } from "../models/Methods"; import { SecondFactorMethod } from "../models/Methods";
import { UserPreferences } from "../models/UserPreferences"; import { UserInfo } from "../models/UserInfo";
export type Method2FA = "u2f" | "totp" | "mobile_push"; 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; method: Method2FA;
} }
@ -31,12 +37,12 @@ export function toString(method: SecondFactorMethod): Method2FA {
} }
} }
export async function getUserPreferences(): Promise<UserPreferences> { export async function getUserPreferences(): Promise<UserInfo> {
const res = await Get<UserPreferencesPayload>(UserInfoPath); const res = await Get<UserInfoPayload>(UserInfoPath);
return { method: toEnum(res.method) }; return { ...res, method: toEnum(res.method) };
} }
export function setPrefered2FAMethod(method: SecondFactorMethod) { export function setPrefered2FAMethod(method: SecondFactorMethod) {
return PostWithOptionalResponse(UserInfo2FAMethodPath, return PostWithOptionalResponse(UserInfo2FAMethodPath,
{ method: toString(method) } as UserPreferencesPayload); { method: toString(method) } as MethodPreferencePayload);
} }

View File

@ -11,7 +11,7 @@ import LoadingPage from "../LoadingPage/LoadingPage";
import { AuthenticationLevel } from "../../services/State"; import { AuthenticationLevel } from "../../services/State";
import { useNotifications } from "../../hooks/NotificationsContext"; import { useNotifications } from "../../hooks/NotificationsContext";
import { useRedirectionURL } from "../../hooks/RedirectionURL"; import { useRedirectionURL } from "../../hooks/RedirectionURL";
import { useUserPreferences } from "../../hooks/UserPreferences"; import { useUserPreferences as userUserInfo } from "../../hooks/UserInfo";
import { SecondFactorMethod } from "../../models/Methods"; import { SecondFactorMethod } from "../../models/Methods";
import { useAutheliaConfiguration } from "../../hooks/Configuration"; import { useAutheliaConfiguration } from "../../hooks/Configuration";
@ -23,7 +23,7 @@ export default function () {
const [firstFactorDisabled, setFirstFactorDisabled] = useState(true); const [firstFactorDisabled, setFirstFactorDisabled] = useState(true);
const [state, fetchState, , fetchStateError] = useAutheliaState(); const [state, fetchState, , fetchStateError] = useAutheliaState();
const [preferences, fetchPreferences, , fetchPreferencesError] = useUserPreferences(); const [userInfo, fetchUserInfo, , fetchUserInfoError] = userUserInfo();
const [configuration, fetchConfiguration, , fetchConfigurationError] = useAutheliaConfiguration(); const [configuration, fetchConfiguration, , fetchConfigurationError] = useAutheliaConfiguration();
const redirect = useCallback((url: string) => history.push(url), [history]); const redirect = useCallback((url: string) => history.push(url), [history]);
@ -34,10 +34,10 @@ export default function () {
// Fetch preferences and configuration when user is authenticated. // Fetch preferences and configuration when user is authenticated.
useEffect(() => { useEffect(() => {
if (state && state.authentication_level >= AuthenticationLevel.OneFactor) { if (state && state.authentication_level >= AuthenticationLevel.OneFactor) {
fetchPreferences(); fetchUserInfo();
fetchConfiguration(); fetchConfiguration();
} }
}, [state, fetchPreferences, fetchConfiguration]); }, [state, fetchUserInfo, fetchConfiguration]);
// Enable first factor when user is unauthenticated. // Enable first factor when user is unauthenticated.
useEffect(() => { useEffect(() => {
@ -62,10 +62,10 @@ export default function () {
// Display an error when preferences fetching fails // Display an error when preferences fetching fails
useEffect(() => { useEffect(() => {
if (fetchPreferencesError) { if (fetchUserInfoError) {
createErrorNotification("There was an issue retrieving user preferences"); createErrorNotification("There was an issue retrieving user preferences");
} }
}, [fetchPreferencesError, createErrorNotification]); }, [fetchUserInfoError, createErrorNotification]);
// Redirect to the correct stage if not enough authenticated // Redirect to the correct stage if not enough authenticated
useEffect(() => { useEffect(() => {
@ -77,18 +77,17 @@ export default function () {
if (state.authentication_level === AuthenticationLevel.Unauthenticated) { if (state.authentication_level === AuthenticationLevel.Unauthenticated) {
setFirstFactorDisabled(false); setFirstFactorDisabled(false);
redirect(`${FirstFactorRoute}${redirectionSuffix}`); redirect(`${FirstFactorRoute}${redirectionSuffix}`);
} else if (state.authentication_level >= AuthenticationLevel.OneFactor && preferences) { } else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo) {
console.log("redirect"); if (userInfo.method === SecondFactorMethod.U2F) {
if (preferences.method === SecondFactorMethod.U2F) {
redirect(`${SecondFactorU2FRoute}${redirectionSuffix}`); redirect(`${SecondFactorU2FRoute}${redirectionSuffix}`);
} else if (preferences.method === SecondFactorMethod.MobilePush) { } else if (userInfo.method === SecondFactorMethod.MobilePush) {
redirect(`${SecondFactorPushRoute}${redirectionSuffix}`); redirect(`${SecondFactorPushRoute}${redirectionSuffix}`);
} else { } else {
redirect(`${SecondFactorTOTPRoute}${redirectionSuffix}`); redirect(`${SecondFactorTOTPRoute}${redirectionSuffix}`);
} }
} }
} }
}, [state, redirectionURL, redirect, preferences, setFirstFactorDisabled]); }, [state, redirectionURL, redirect, userInfo, setFirstFactorDisabled]);
const handleFirstFactorSuccess = async (redirectionURL: string | undefined) => { const handleFirstFactorSuccess = async (redirectionURL: string | undefined) => {
if (redirectionURL) { if (redirectionURL) {
@ -125,12 +124,12 @@ export default function () {
</ComponentOrLoading> </ComponentOrLoading>
</Route> </Route>
<Route path={SecondFactorRoute}> <Route path={SecondFactorRoute}>
{state && preferences && configuration ? <SecondFactorForm {state && userInfo && configuration ? <SecondFactorForm
username={state.username} username={state.username}
authenticationLevel={state.authentication_level} authenticationLevel={state.authentication_level}
userPreferences={preferences} userInfo={userInfo}
configuration={configuration} configuration={configuration}
onMethodChanged={() => fetchPreferences()} onMethodChanged={() => fetchUserInfo()}
onAuthenticationSuccess={handleSecondFactorSuccess} /> : null} onAuthenticationSuccess={handleSecondFactorSuccess} /> : null}
</Route> </Route>
<Route path="/"> <Route path="/">

View File

@ -12,9 +12,7 @@ interface IconWithContextProps {
export default function (props: IconWithContextProps) { export default function (props: IconWithContextProps) {
const iconSize = 64; const iconSize = 64;
const style = makeStyles(theme => ({ const style = makeStyles(theme => ({
root: { root: {},
height: iconSize + theme.spacing(6),
},
iconContainer: { iconContainer: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -26,7 +24,6 @@ export default function (props: IconWithContextProps) {
}, },
context: { context: {
display: "block", display: "block",
height: theme.spacing(6),
} }
}))(); }))();

View File

@ -1,22 +1,50 @@
import React, { ReactNode } from "react"; import React, { ReactNode, Fragment } from "react";
import { makeStyles, Typography, Link } from "@material-ui/core"; 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; id: string;
title: string; title: string;
explanation: string; explanation: string;
state: State;
children: ReactNode; children: ReactNode;
onRegisterClick?: () => void; onRegisterClick?: () => void;
} }
export default function (props: MethodContainerProps) { export default function (props: Props) {
const style = useStyles(); const style = useStyles();
let container: ReactNode;
switch (props.state) {
case State.ALREADY_AUTHENTICATED:
container = <AlreadyAuthenticatedContainer />
break;
case State.NOT_REGISTERED:
container = <NotRegisteredContainer />
break;
case State.METHOD:
container = <MethodContainer explanation={props.explanation}>
{props.children}
</MethodContainer>
break;
}
return ( return (
<div id={props.id}> <div id={props.id}>
<Typography variant="h6">{props.title}</Typography> <Typography variant="h6">{props.title}</Typography>
<div className={style.icon}>{props.children}</div> <div className={style.container} id="2fa-container">
<Typography>{props.explanation}</Typography> <div className={style.containerFlex}>
{container}
</div>
</div>
{props.onRegisterClick {props.onRegisterClick
? <Link component="button" ? <Link component="button"
id="register-link" id="register-link"
@ -29,8 +57,51 @@ export default function (props: MethodContainerProps) {
} }
const useStyles = makeStyles(theme => ({ const useStyles = makeStyles(theme => ({
icon: { container: {
paddingTop: theme.spacing(2), height: "200px",
paddingBottom: theme.spacing(2),
}, },
containerFlex: {
display: "flex",
flexWrap: "wrap",
height: "100%",
width: "100%",
alignItems: "center",
alignContent: "center",
justifyContent: "center",
}
})); }));
function AlreadyAuthenticatedContainer() {
const theme = useTheme();
return (
<Fragment>
<div style={{ marginBottom: theme.spacing(2), flex: "0 0 100%" }}><SuccessIcon /></div>
<Typography style={{ color: "green" }}>Authenticated!</Typography>
</Fragment>
)
}
function NotRegisteredContainer() {
const theme = useTheme();
return (
<Fragment>
<div style={{ marginBottom: theme.spacing(2), flex: "0 0 100%" }}><InformationIcon /></div>
<Typography style={{ color: "#5858ff" }}>Register your first device by clicking on the link below</Typography>
</Fragment>
)
}
interface MethodContainerProps {
explanation: string;
children: ReactNode;
}
function MethodContainer(props: MethodContainerProps) {
const theme = useTheme();
return (
<Fragment>
<div style={{ marginBottom: theme.spacing(2) }}>{props.children}</div>
<Typography>{props.explanation}</Typography>
</Fragment>
)
}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import MethodContainer from "./MethodContainer"; import MethodContainer, { State as MethodContainerState } from "./MethodContainer";
import OTPDial from "./OTPDial"; import OTPDial from "./OTPDial";
import { completeTOTPSignIn } from "../../../services/OneTimePassword"; import { completeTOTPSignIn } from "../../../services/OneTimePassword";
import { useRedirectionURL } from "../../../hooks/RedirectionURL"; import { useRedirectionURL } from "../../../hooks/RedirectionURL";
@ -15,6 +15,7 @@ export enum State {
export interface Props { export interface Props {
id: string; id: string;
authenticationLevel: AuthenticationLevel; authenticationLevel: AuthenticationLevel;
registered: boolean;
onRegisterClick: () => void; onRegisterClick: () => void;
onSignInError: (err: Error) => void; onSignInError: (err: Error) => void;
@ -65,11 +66,19 @@ export default function (props: Props) {
useEffect(() => { signInFunc() }, [signInFunc]); 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 ( return (
<MethodContainer <MethodContainer
id={props.id} id={props.id}
title="One-Time Password" title="One-Time Password"
explanation="Enter one-time password" explanation="Enter one-time password"
state={methodState}
onRegisterClick={props.onRegisterClick}> onRegisterClick={props.onRegisterClick}>
<OTPDial <OTPDial
passcode={passcode} passcode={passcode}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useCallback, useState, ReactNode } from "react"; import React, { useEffect, useCallback, useState, ReactNode } from "react";
import MethodContainer from "./MethodContainer"; import MethodContainer, { State as MethodContainerState } from "./MethodContainer";
import PushNotificationIcon from "../../../components/PushNotificationIcon"; import PushNotificationIcon from "../../../components/PushNotificationIcon";
import { completePushNotificationSignIn } from "../../../services/PushNotification"; import { completePushNotificationSignIn } from "../../../services/PushNotification";
import { Button, makeStyles } from "@material-ui/core"; import { Button, makeStyles } from "@material-ui/core";
@ -82,11 +82,17 @@ export default function (props: Props) {
icon = <FailureIcon />; icon = <FailureIcon />;
} }
let methodState = MethodContainerState.METHOD;
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
methodState = MethodContainerState.ALREADY_AUTHENTICATED;
}
return ( return (
<MethodContainer <MethodContainer
id={props.id} id={props.id}
title="Push Notification" title="Push Notification"
explanation="A notification has been sent to your smartphone"> explanation="A notification has been sent to your smartphone"
state={methodState}>
<div className={style.icon}> <div className={style.icon}>
{icon} {icon}
</div> </div>

View File

@ -17,7 +17,7 @@ import {
SecondFactorPushRoute, SecondFactorU2FRoute, SecondFactorRoute SecondFactorPushRoute, SecondFactorU2FRoute, SecondFactorRoute
} from "../../../Routes"; } from "../../../Routes";
import { setPrefered2FAMethod } from "../../../services/UserPreferences"; import { setPrefered2FAMethod } from "../../../services/UserPreferences";
import { UserPreferences } from "../../../models/UserPreferences"; import { UserInfo } from "../../../models/UserInfo";
import { Configuration } from "../../../models/Configuration"; import { Configuration } from "../../../models/Configuration";
import u2fApi from "u2f-api"; import u2fApi from "u2f-api";
import { AuthenticationLevel } from "../../../services/State"; import { AuthenticationLevel } from "../../../services/State";
@ -28,7 +28,7 @@ export interface Props {
username: string; username: string;
authenticationLevel: AuthenticationLevel; authenticationLevel: AuthenticationLevel;
userPreferences: UserPreferences; userInfo: UserInfo;
configuration: Configuration; configuration: Configuration;
onMethodChanged: (method: SecondFactorMethod) => void; onMethodChanged: (method: SecondFactorMethod) => void;
@ -109,6 +109,8 @@ export default function (props: Props) {
<OneTimePasswordMethod <OneTimePasswordMethod
id="one-time-password-method" id="one-time-password-method"
authenticationLevel={props.authenticationLevel} authenticationLevel={props.authenticationLevel}
// Whether the user has a TOTP secret registered already
registered={props.userInfo.has_totp}
onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)} onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)}
onSignInError={err => createErrorNotification(err.message)} onSignInError={err => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} /> onSignInSuccess={props.onAuthenticationSuccess} />
@ -117,6 +119,8 @@ export default function (props: Props) {
<SecurityKeyMethod <SecurityKeyMethod
id="security-key-method" id="security-key-method"
authenticationLevel={props.authenticationLevel} authenticationLevel={props.authenticationLevel}
// Whether the user has a U2F device registered already
registered={props.userInfo.has_u2f}
onRegisterClick={initiateRegistration(initiateU2FRegistrationProcess)} onRegisterClick={initiateRegistration(initiateU2FRegistrationProcess)}
onSignInError={err => createErrorNotification(err.message)} onSignInError={err => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} /> onSignInSuccess={props.onAuthenticationSuccess} />

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState, Fragment } from "react"; 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 { makeStyles, Button, useTheme } from "@material-ui/core";
import { initiateU2FSignin, completeU2FSignin } from "../../../services/SecurityKey"; import { initiateU2FSignin, completeU2FSignin } from "../../../services/SecurityKey";
import u2fApi from "u2f-api"; import u2fApi from "u2f-api";
@ -8,7 +8,6 @@ import { useIsMountedRef } from "../../../hooks/Mounted";
import { useTimer } from "../../../hooks/Timer"; import { useTimer } from "../../../hooks/Timer";
import LinearProgressBar from "../../../components/LinearProgressBar"; import LinearProgressBar from "../../../components/LinearProgressBar";
import FingerTouchIcon from "../../../components/FingerTouchIcon"; import FingerTouchIcon from "../../../components/FingerTouchIcon";
import SuccessIcon from "../../../components/SuccessIcon";
import FailureIcon from "../../../components/FailureIcon"; import FailureIcon from "../../../components/FailureIcon";
import IconWithContext from "./IconWithContext"; import IconWithContext from "./IconWithContext";
import { CSSProperties } from "@material-ui/styles"; import { CSSProperties } from "@material-ui/styles";
@ -17,13 +16,13 @@ import { AuthenticationLevel } from "../../../services/State";
export enum State { export enum State {
WaitTouch = 1, WaitTouch = 1,
SigninInProgress = 2, SigninInProgress = 2,
Success = 3, Failure = 3,
Failure = 4,
} }
export interface Props { export interface Props {
id: string; id: string;
authenticationLevel: AuthenticationLevel; authenticationLevel: AuthenticationLevel;
registered: boolean;
onRegisterClick: () => void; onRegisterClick: () => void;
onSignInError: (err: Error) => void; onSignInError: (err: Error) => void;
@ -44,7 +43,7 @@ export default function (props: Props) {
const doInitiateSignIn = useCallback(async () => { const doInitiateSignIn = useCallback(async () => {
// If user is already authenticated, we don't initiate sign in process. // 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; return;
} }
@ -69,8 +68,7 @@ export default function (props: Props) {
setState(State.SigninInProgress); setState(State.SigninInProgress);
const res = await completeU2FSignin(signResponse, redirectionURL); const res = await completeU2FSignin(signResponse, redirectionURL);
setState(State.Success); onSignInSuccessCallback(res ? res.redirect : undefined);
setTimeout(() => { onSignInSuccessCallback(res ? res.redirect : undefined) }, 1500);
} catch (err) { } catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime, // 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. // 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")); onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
setState(State.Failure); setState(State.Failure);
} }
}, [onSignInSuccessCallback, onSignInErrorCallback, redirectionURL, mounted, triggerTimer, props.authenticationLevel]); }, [onSignInSuccessCallback, onSignInErrorCallback, redirectionURL, mounted, triggerTimer, props.authenticationLevel, props.registered]);
// Set successful state if user is already authenticated.
useEffect(() => {
if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
setState(State.Success);
}
}, [props.authenticationLevel, setState]);
useEffect(() => { doInitiateSignIn() }, [doInitiateSignIn]); 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 ( return (
<MethodContainer <MethodContainer
id={props.id} id={props.id}
title="Security Key" title="Security Key"
explanation="Touch the token of your security key" explanation="Touch the token of your security key"
state={methodState}
onRegisterClick={props.onRegisterClick}> onRegisterClick={props.onRegisterClick}>
<div className={style.icon}> <div className={style.icon}>
<Icon state={state} timer={timerPercent} onRetryClick={doInitiateSignIn} /> <Icon state={state} timer={timerPercent} onRetryClick={doInitiateSignIn} />
@ -134,15 +133,9 @@ function Icon(props: IconProps) {
context={<Button color="secondary" onClick={props.onRetryClick}>Retry</Button>} context={<Button color="secondary" onClick={props.onRetryClick}>Retry</Button>}
className={state === State.Failure ? undefined : "hidden"} /> className={state === State.Failure ? undefined : "hidden"} />
const success = <IconWithContext
icon={<SuccessIcon />}
context={<div style={{ color: "green", padding: theme.spacing() }}>Success!</div>}
className={state === State.Success || state === State.SigninInProgress ? undefined : "hidden"} />
return ( return (
<Fragment> <Fragment>
{touch} {touch}
{success}
{failure} {failure}
</Fragment> </Fragment>
) )