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 { useRemoteCall } from "../hooks/RemoteCall";
import { useRemoteCall } from "./RemoteCall";
export function useUserPreferences() {
return useRemoteCall(getUserPreferences, []);

View File

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

View File

@ -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<UserPreferences> {
const res = await Get<UserPreferencesPayload>(UserInfoPath);
return { method: toEnum(res.method) };
export async function getUserPreferences(): Promise<UserInfo> {
const res = await Get<UserInfoPayload>(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);
}

View File

@ -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 () {
</ComponentOrLoading>
</Route>
<Route path={SecondFactorRoute}>
{state && preferences && configuration ? <SecondFactorForm
{state && userInfo && configuration ? <SecondFactorForm
username={state.username}
authenticationLevel={state.authentication_level}
userPreferences={preferences}
userInfo={userInfo}
configuration={configuration}
onMethodChanged={() => fetchPreferences()}
onMethodChanged={() => fetchUserInfo()}
onAuthenticationSuccess={handleSecondFactorSuccess} /> : null}
</Route>
<Route path="/">

View File

@ -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),
}
}))();

View File

@ -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 = <AlreadyAuthenticatedContainer />
break;
case State.NOT_REGISTERED:
container = <NotRegisteredContainer />
break;
case State.METHOD:
container = <MethodContainer explanation={props.explanation}>
{props.children}
</MethodContainer>
break;
}
return (
<div id={props.id}>
<Typography variant="h6">{props.title}</Typography>
<div className={style.icon}>{props.children}</div>
<Typography>{props.explanation}</Typography>
<div className={style.container} id="2fa-container">
<div className={style.containerFlex}>
{container}
</div>
</div>
{props.onRegisterClick
? <Link component="button"
id="register-link"
@ -29,8 +57,51 @@ export default function (props: MethodContainerProps) {
}
const useStyles = makeStyles(theme => ({
icon: {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
container: {
height: "200px",
},
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 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 (
<MethodContainer
id={props.id}
title="One-Time Password"
explanation="Enter one-time password"
state={methodState}
onRegisterClick={props.onRegisterClick}>
<OTPDial
passcode={passcode}

View File

@ -1,5 +1,5 @@
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 { completePushNotificationSignIn } from "../../../services/PushNotification";
import { Button, makeStyles } from "@material-ui/core";
@ -82,11 +82,17 @@ export default function (props: Props) {
icon = <FailureIcon />;
}
let methodState = MethodContainerState.METHOD;
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
methodState = MethodContainerState.ALREADY_AUTHENTICATED;
}
return (
<MethodContainer
id={props.id}
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}>
{icon}
</div>

View File

@ -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) {
<OneTimePasswordMethod
id="one-time-password-method"
authenticationLevel={props.authenticationLevel}
// Whether the user has a TOTP secret registered already
registered={props.userInfo.has_totp}
onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)}
onSignInError={err => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} />
@ -117,6 +119,8 @@ export default function (props: Props) {
<SecurityKeyMethod
id="security-key-method"
authenticationLevel={props.authenticationLevel}
// Whether the user has a U2F device registered already
registered={props.userInfo.has_u2f}
onRegisterClick={initiateRegistration(initiateU2FRegistrationProcess)}
onSignInError={err => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} />

View File

@ -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 (
<MethodContainer
id={props.id}
title="Security Key"
explanation="Touch the token of your security key"
state={methodState}
onRegisterClick={props.onRegisterClick}>
<div className={style.icon}>
<Icon state={state} timer={timerPercent} onRetryClick={doInitiateSignIn} />
@ -134,15 +133,9 @@ function Icon(props: IconProps) {
context={<Button color="secondary" onClick={props.onRetryClick}>Retry</Button>}
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 (
<Fragment>
{touch}
{success}
{failure}
</Fragment>
)