mirror of
https://github.com/0rangebananaspy/authelia.git
synced 2024-09-14 22:47:21 +07:00
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:
parent
5942e00412
commit
5f8726fe87
7
web/src/components/InformationIcon.test.tsx
Normal file
7
web/src/components/InformationIcon.test.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
import { mount } from "enzyme";
|
||||
import InformationIcon from "./InformationIcon";
|
||||
|
||||
it('renders without crashing', () => {
|
||||
mount(<InformationIcon />);
|
||||
});
|
11
web/src/components/InformationIcon.tsx
Normal file
11
web/src/components/InformationIcon.tsx
Normal 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" />
|
||||
)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { getUserPreferences } from "../services/UserPreferences";
|
||||
import { useRemoteCall } from "../hooks/RemoteCall";
|
||||
import { useRemoteCall } from "./RemoteCall";
|
||||
|
||||
export function useUserPreferences() {
|
||||
return useRemoteCall(getUserPreferences, []);
|
|
@ -1,5 +1,7 @@
|
|||
import { SecondFactorMethod } from "./Methods";
|
||||
|
||||
export interface UserPreferences {
|
||||
export interface UserInfo {
|
||||
method: SecondFactorMethod;
|
||||
has_u2f: boolean;
|
||||
has_totp: boolean;
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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="/">
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}))();
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue
Block a user