Skip to content

Commit

Permalink
feat(web): per-device two factor method
Browse files Browse the repository at this point in the history
This implements a per-device two factor method selection. The selection from the UI changes this value and the account wide selection has been moved to settings.

Closes #1699

Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
  • Loading branch information
james-d-elliott committed Mar 4, 2024
1 parent 87747a5 commit b33dabb
Show file tree
Hide file tree
Showing 15 changed files with 309 additions and 53 deletions.
1 change: 1 addition & 0 deletions internal/middlewares/require_first_factor.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func RequireElevated1FA(next RequestHandler) RequestHandler {
}

ctx.ReplyForbidden()

return
}

Expand Down
1 change: 1 addition & 0 deletions web/src/constants/LocalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const LocalStorageSecondFactorMethod = "second_factor_method";
26 changes: 4 additions & 22 deletions web/src/i18n/detectors/localStorageCustom.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,16 @@
import { CustomDetector, DetectorOptions } from "i18next-browser-languagedetector";

let hasLocalStorageSupport: null | boolean = null;
const testKey = "authelia.test";
const testValue = "foo";

const localStorageAvailable = () => {
if (hasLocalStorageSupport !== null) return hasLocalStorageSupport;

if (typeof window !== "undefined" && window.localStorage !== null) {
hasLocalStorageSupport = true;

try {
window.localStorage.setItem(testKey, testValue);
window.localStorage.removeItem(testKey);
} catch (e) {
hasLocalStorageSupport = false;
}
}

return hasLocalStorageSupport;
};
import { getLocalStorage } from "@services/LocalStorage";

const LocalStorageCustomDetector: CustomDetector = {
name: "localStorageCustom",

lookup(options: DetectorOptions): string | undefined {
let found;

if (options.lookupLocalStorage && localStorageAvailable()) {
const lng = window.localStorage.getItem(options.lookupLocalStorage);
if (options.lookupLocalStorage) {
const lng = getLocalStorage(options.lookupLocalStorage);

if (lng && lng !== "") {
found = lng;
}
Expand Down
4 changes: 2 additions & 2 deletions web/src/services/Configuration.ts
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Configuration } from "@models/Configuration";
import { ConfigurationPath } from "@services/Api";
import { Get } from "@services/Client";
import { Method2FA, toEnum } from "@services/UserInfo";
import { Method2FA, toSecondFactorMethod } from "@services/UserInfo";

interface ConfigurationPayload {
available_methods: Method2FA[];
}

export async function getConfiguration(): Promise<Configuration> {
const config = await Get<ConfigurationPayload>(ConfigurationPath);
return { ...config, available_methods: new Set(config.available_methods.map(toEnum)) };
return { ...config, available_methods: new Set(config.available_methods.map(toSecondFactorMethod)) };
}
66 changes: 66 additions & 0 deletions web/src/services/LocalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { LocalStorageSecondFactorMethod } from "@constants/LocalStorage.ts";
import { SecondFactorMethod } from "@models/Methods.ts";
import { Method2FA, isMethod2FA, toMethod2FA, toSecondFactorMethod } from "@services/UserInfo.ts";

let hasLocalStorageSupport: null | boolean = null;
const testKey = "authelia.test";
const testValue = "foo";

export function localStorageAvailable() {
if (hasLocalStorageSupport !== null) return hasLocalStorageSupport;

hasLocalStorageSupport = false;

if (typeof window !== "undefined" && window.localStorage !== null) {
hasLocalStorageSupport = true;

try {
window.localStorage.setItem(testKey, testValue);
window.localStorage.removeItem(testKey);
} catch (e) {
hasLocalStorageSupport = false;
}
}

return hasLocalStorageSupport;
}

export function removeLocalStorage(key: string) {
if (!localStorageAvailable()) return false;

window.localStorage.removeItem(key);

return true;
}

export function getLocalStorage(key: string) {
if (!localStorageAvailable()) return null;

return window.localStorage.getItem(key);
}

export function setLocalStorage(key: string, value: string) {
if (!localStorageAvailable()) return false;

window.localStorage.setItem(key, value);

return true;
}

export function setLocalStorageSecondFactorMethod(value: SecondFactorMethod): boolean {
return setLocalStorage(LocalStorageSecondFactorMethod, toMethod2FA(value));
}

export function getLocalStorageSecondFactorMethod(global: SecondFactorMethod): SecondFactorMethod {
const method = getLocalStorage(LocalStorageSecondFactorMethod);

if (method === null) return global;

if (!isMethod2FA(method)) {
return global;
}

const local: Method2FA = method as "webauthn" | "totp" | "mobile_push";

return toSecondFactorMethod(local);
}
14 changes: 9 additions & 5 deletions web/src/services/UserInfo.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ export interface MethodPreferencePayload {
method: Method2FA;
}

export function toEnum(method: Method2FA): SecondFactorMethod {
export function isMethod2FA(method: string) {
return ["webauthn", "totp", "mobile_push"].includes(method);
}

export function toSecondFactorMethod(method: Method2FA): SecondFactorMethod {
switch (method) {
case "totp":
return SecondFactorMethod.TOTP;
Expand All @@ -28,7 +32,7 @@ export function toEnum(method: Method2FA): SecondFactorMethod {
}
}

export function toString(method: SecondFactorMethod): Method2FA {
export function toMethod2FA(method: SecondFactorMethod): Method2FA {
switch (method) {
case SecondFactorMethod.TOTP:
return "totp";
Expand All @@ -41,14 +45,14 @@ export function toString(method: SecondFactorMethod): Method2FA {

export async function postUserInfo(): Promise<UserInfo> {
const res = await Post<UserInfoPayload>(UserInfoPath);
return { ...res, method: toEnum(res.method) };
return { ...res, method: toSecondFactorMethod(res.method) };
}

export async function getUserInfo(): Promise<UserInfo> {
const res = await Get<UserInfoPayload>(UserInfoPath);
return { ...res, method: toEnum(res.method) };
return { ...res, method: toSecondFactorMethod(res.method) };
}

export function setPreferred2FAMethod(method: SecondFactorMethod) {
return PostWithOptionalResponse(UserInfo2FAMethodPath, { method: toString(method) } as MethodPreferencePayload);
return PostWithOptionalResponse(UserInfo2FAMethodPath, { method: toMethod2FA(method) } as MethodPreferencePayload);
}
7 changes: 5 additions & 2 deletions web/src/views/LoginPortal/LoginPortal.tsx
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useRouterNavigate } from "@hooks/RouterNavigate";
import { useAutheliaState } from "@hooks/State";
import { useUserInfoPOST } from "@hooks/UserInfo";
import { SecondFactorMethod } from "@models/Methods";
import { getLocalStorageSecondFactorMethod } from "@services/LocalStorage.ts";
import { checkSafeRedirection } from "@services/SafeRedirection";
import { AuthenticationLevel } from "@services/State";
import LoadingPage from "@views/LoadingPage/LoadingPage";
Expand Down Expand Up @@ -128,9 +129,11 @@ const LoginPortal = function (props: Props) {
if (configuration.available_methods.size === 0) {
navigate(AuthenticatedRoute, false);
} else {
if (userInfo.method === SecondFactorMethod.WebAuthn) {
const method = getLocalStorageSecondFactorMethod(userInfo.method);

if (method === SecondFactorMethod.WebAuthn) {
navigate(`${SecondFactorRoute}${SecondFactorWebAuthnSubRoute}`);
} else if (userInfo.method === SecondFactorMethod.MobilePush) {
} else if (method === SecondFactorMethod.MobilePush) {
navigate(`${SecondFactorRoute}${SecondFactorPushSubRoute}`);
} else {
navigate(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}`);
Expand Down
4 changes: 2 additions & 2 deletions web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { SecondFactorMethod } from "@models/Methods";
export interface Props {
open: boolean;
methods: Set<SecondFactorMethod>;
webauthnSupported: boolean;
webauthn: boolean;

onClose: () => void;
onClick: (method: SecondFactorMethod) => void;
Expand All @@ -39,7 +39,7 @@ const MethodSelectionDialog = function (props: Props) {
onClick={() => props.onClick(SecondFactorMethod.TOTP)}
/>
) : null}
{props.methods.has(SecondFactorMethod.WebAuthn) && props.webauthnSupported ? (
{props.methods.has(SecondFactorMethod.WebAuthn) && props.webauthn ? (
<MethodItem
id="webauthn-option"
method={translate("Security Key - WebAuthn")}
Expand Down
16 changes: 13 additions & 3 deletions web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import LoginLayout from "@layouts/LoginLayout";
import { Configuration } from "@models/Configuration";
import { SecondFactorMethod } from "@models/Methods";
import { UserInfo } from "@models/UserInfo";
import { setLocalStorageSecondFactorMethod } from "@services/LocalStorage.ts";
import { AuthenticationLevel } from "@services/State";
import { setPreferred2FAMethod } from "@services/UserInfo";
import MethodSelectionDialog from "@views/LoginPortal/SecondFactor/MethodSelectionDialog";
Expand Down Expand Up @@ -54,10 +55,19 @@ const SecondFactorForm = function (props: Props) {
};

const handleMethodSelected = async (method: SecondFactorMethod) => {
const setLocal = setLocalStorageSecondFactorMethod(method);

if (!setLocal) {
await handleMethodSelectedFallback(method);
}

setMethodSelectionOpen(false);
props.onMethodChanged();
};

const handleMethodSelectedFallback = async (method: SecondFactorMethod) => {
try {
await setPreferred2FAMethod(method);
setMethodSelectionOpen(false);
props.onMethodChanged();
} catch (err) {
console.error(err);
createErrorNotification("There was an issue updating preferred second factor method");
Expand All @@ -79,7 +89,7 @@ const SecondFactorForm = function (props: Props) {
<MethodSelectionDialog
open={methodSelectionOpen}
methods={props.configuration.available_methods}
webauthnSupported={stateWebAuthnSupported}
webauthn={stateWebAuthnSupported}
onClose={() => setMethodSelectionOpen(false)}
onClick={handleMethodSelected}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const TOTPConfiguration = function (props: Props) {
})}
</Typography>
</Stack>
<Tooltip title={translate("Remove the Time-based One Time Password configuration")}>
<Tooltip title={translate("Remove the Time-based One-Time Password configuration")}>
<IconButton color="primary" onClick={props.handleDelete}>
<DeleteIcon />
</IconButton>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const TOTPDeleteDialog = function (props: Props) {
return;
}

createSuccessNotification(translate("Successfully deleted the One Time Password configuration"));
createSuccessNotification(translate("Successfully deleted the One-Time Password configuration"));

props.handleClose();
};
Expand All @@ -53,8 +53,8 @@ const TOTPDeleteDialog = function (props: Props) {
<DeleteDialog
open={props.open}
handleClose={handleClose}
title={translate("Remove One Time Password")}
text={translate("Are you sure you want to remove the Time-based One Time Password from from your account")}
title={translate("Remove One-Time Password")}
text={translate("Are you sure you want to remove the Time-based One-Time Password from from your account")}
/>
);
};
Expand Down
6 changes: 3 additions & 3 deletions web/src/views/Settings/TwoFactorAuthentication/TOTPPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,13 @@ const TOTPPanel = function (props: Props) {
<Paper variant={"outlined"}>
<Grid container spacing={2} padding={2}>
<Grid xs={12} lg={8}>
<Typography variant={"h5"}>{translate("One Time Password")}</Typography>
<Typography variant={"h5"}>{translate("One-Time Password")}</Typography>
</Grid>
{props.config === undefined || props.config === null ? (
<Fragment>
<Grid xs={2}>
<Tooltip
title={translate("Click to add a Time-based One Time Password to your account")}
title={translate("Click to add a Time-based One-Time Password to your account")}
>
<Button
variant="outlined"
Expand All @@ -119,7 +119,7 @@ const TOTPPanel = function (props: Props) {
<Grid xs={12}>
<Typography variant={"subtitle2"}>
{translate(
"The One Time Password has not been registered. If you'd like to register it click add.",
"The One-Time Password has not been registered. If you'd like to register it click add.",
)}
</Typography>
</Grid>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ const TOTPRegisterDialog = function (props: Props) {

return (
<Dialog open={props.open} onClose={handleOnClose} maxWidth={"lg"} fullWidth={true}>
<DialogTitle>{translate("Register One Time Password (TOTP)")}</DialogTitle>
<DialogTitle>{translate("Register One-Time Password (TOTP)")}</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 3 }}>
{translate("This dialog allows registration of the One-Time Password.")}
Expand Down

0 comments on commit b33dabb

Please sign in to comment.