Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "next",
"baseBranch": "master",
"updateInternalDependencies": "patch",
"ignore": [],
"privatePackages": {
Expand Down
11 changes: 11 additions & 0 deletions .changeset/small-moons-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@wso2is/admin.identity-providers.v1": minor
"@wso2is/admin.connections.v1": minor
"@wso2is/admin.core.v1": minor
"@wso2is/identity-apps-core": minor
"@wso2is/myaccount": minor
"@wso2is/console": minor
"@wso2is/i18n": minor
---

Add multi-device support for push authentication
Original file line number Diff line number Diff line change
Expand Up @@ -1634,6 +1634,11 @@
"update": [],
"delete": []
{% endif %}
},
"properties": {
"maxPushDeviceLimitPerUser": {% if push_device_management.max_device_limit_per_user is defined %}{{ push_device_management.max_device_limit_per_user }}{% else %}10{% endif %},
"multiplePushDeviceSupportEnabled": {% if push_device_management.multiple_device_support_enabled is defined %}{{ push_device_management.multiple_device_support_enabled }}{% else %}true{% endif %},
"pushDeviceRegistrationNotificationsEnabled": {% if push_device_management.device_registration_notifications_enabled is defined %}{{ push_device_management.device_registration_notifications_enabled }}{% else %}true{% endif %}
}
},
{% endif %}
Expand Down
5 changes: 5 additions & 0 deletions apps/console/src/public/deployment.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1293,6 +1293,11 @@
],
"enabled": true,
"featureFlags": [],
"properties": {
"maxPushDeviceLimitPerUser": 10,
"multiplePushDeviceSupportEnabled": true,
"pushDeviceRegistrationNotificationsEnabled": true
},
"scopes": {
"create": [
"internal_idp_create"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,9 @@
"update": [],
"delete": []
{% endif %}
},
"properties": {
"multiplePushDeviceSupportEnabled": {% if push_device_management.multiple_device_support_enabled is defined %}{{ push_device_management.multiple_device_support_enabled }}{% else %}true{% endif %}
}
}
},
Expand Down
35 changes: 35 additions & 0 deletions apps/myaccount/src/api/preference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import { AsgardeoSPAClient, HttpError, HttpInstance, HttpRequestConfig, HttpResponse } from "@asgardeo/auth-react";
import { HttpMethods, PreferenceRequest } from "../models";
import { ConfigPreferenceRequestInterface, ConfigPreferenceResponseInterface } from "../models/push-authenticator";
import { store } from "../store";

/**
Expand Down Expand Up @@ -53,3 +54,37 @@ export const getPreference = (data: PreferenceRequest[]): Promise<any> => {
return Promise.reject(error);
});
};

/**
* Get server configuration preferences for the tenant.
*
* @param data - Resource types, names and properties to fetch.
* @returns Promise resolving to the config preference response.
*/
export const getConfigPreferences = (
data: ConfigPreferenceRequestInterface[]
): Promise<ConfigPreferenceResponseInterface[]> => {
const requestConfig: HttpRequestConfig = {
data,
headers: {
"Accept": "application/json",
"Content-Type": "application/json"
},
method: HttpMethods.POST,
url: store.getState().config.endpoints.serverConfigPreferences
};

return httpClient(requestConfig)
.then((response: HttpResponse) => {
if (response.status !== 200) {
return Promise.reject(
new Error(`Failed to fetch config preferences. Status: ${response.status}`)
);
}

return Promise.resolve(response?.data as ConfigPreferenceResponseInterface[]);
})
.catch((error: HttpError) => {
return Promise.reject(error);
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,13 @@ export const PushAuthenticator: React.FunctionComponent<PushAuthenticatorProps>

const {
deleteRegisteredDevice,
deviceLimit,
handlePushAuthenticatorInitCancel,
handlePushAuthenticatorSetupSubmit,
initPushAuthenticatorRegFlow,
isConfigPushAuthenticatorModalOpen,
isDeviceLimitReached,
isPushDeviceMgtConfigLoading,
isRegisteredDeviceListLoading,
qrCode,
registeredDeviceList,
Expand Down Expand Up @@ -111,7 +114,7 @@ export const PushAuthenticator: React.FunctionComponent<PushAuthenticatorProps>
* @returns Modal content
*/
const renderPushAuthenticatorWizardContent = (): React.ReactElement => {
if (!registeredDeviceList || registeredDeviceList?.length === 0) {
if (qrCode) {
return (
<Segment basic>
<h5 className=" text-center"> { t(translateKey + "modals.scan.heading") }</h5>
Expand Down Expand Up @@ -195,7 +198,7 @@ export const PushAuthenticator: React.FunctionComponent<PushAuthenticatorProps>
* @returns Modal actions
*/
const renderPushAuthenticatorWizardActions = (): React.ReactElement => {
if (registeredDeviceList?.length > 0) {
if (!qrCode) {
return (
<Button
primary
Expand Down Expand Up @@ -279,22 +282,31 @@ export const PushAuthenticator: React.FunctionComponent<PushAuthenticatorProps>
</List.Content>
</Grid.Column>
<Grid.Column width={ 3 } className="last-column">
{ (!registeredDeviceList || registeredDeviceList?.length === 0) && (
{(
<List.Content floated="right">
<Popup
trigger={
(<Icon
link={ true }
onClick={ initPushAuthenticatorRegFlow }
link={ !isDeviceLimitReached }
onClick={ isDeviceLimitReached ? undefined : initPushAuthenticatorRegFlow }
className="list-icon padded-icon"
size="small"
color="grey"
name="plus"
disabled={ isRegisteredDeviceListLoading }
disabled={
isRegisteredDeviceListLoading
|| isPushDeviceMgtConfigLoading
|| isDeviceLimitReached
}
data-componentId={ `${componentId}-view-button` }
/>)
}
content={ t(translateKey + "addHint") }
content={
isDeviceLimitReached
? t(translateKey + "deviceLimitReachedHint",
{ limit: deviceLimit })
: t(translateKey + "addHint")
}
inverted
/>
</List.Content>
Expand Down
1 change: 1 addition & 0 deletions apps/myaccount/src/configs/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export class Config {
preference: `${this.getDeploymentConfig()?.serverHost}/api/server/v1/identity-governance/preferences`,
profileSchemas: `${this.getDeploymentConfig()?.serverHost}/scim2/Schemas`,
push: `${this.getDeploymentConfig()?.serverHost}/api/users/v1/me/push`,
serverConfigPreferences: `${this.getDeploymentConfig()?.serverHost}/api/server/v1/configs/preferences`,
revoke: `${this.getDeploymentConfig()?.serverHost}/oauth2/revoke`,
sessions: `${this.getDeploymentConfig()?.serverHost}/api/users/v1/me/sessions`,
token: `${this.getDeploymentConfig()?.serverHost}/oauth2/token`,
Expand Down
13 changes: 13 additions & 0 deletions apps/myaccount/src/constants/mfa-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,17 @@ export class MultiFactorAuthenticationConstants {

public static readonly MFA_ENABLED_AUTHENTICATOR_UPDATE_ERROR: string = "Received an invalid status " +
"code while updating enabled authenticators.";

// Push authenticator device management constants
public static readonly RESOURCE_TYPE: string = "DEVICE_MANAGEMENT";

public static readonly DEVICE_MGT_RESOURCE_NAME: string = "PUSH_DEVICE_MANAGEMENT";

public static readonly PROPERTY_ENABLE_MULTIPLE_DEVICE_ENROLLMENT: string = "enableMultipleDeviceEnrollment";

public static readonly PROPERTY_MAXIMUM_PUSH_DEVICE_LIMIT: string = "maximumDeviceLimit";

public static readonly DEFAULT_MULTIPLE_PUSH_DEVICE_LIMIT: number = 2;

public static readonly SINGLE_PUSH_DEVICE_LIMIT: number = 1;
}
105 changes: 99 additions & 6 deletions apps/myaccount/src/hooks/use-push-authenticator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,22 @@

import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { Dispatch } from "redux";
import useGetPushAuthRegisteredDevices from "./use-get-push-auth-registered-devices";
import { deletePushAuthRegisteredDevice, initPushAuthenticatorQRCode } from "../api/multi-factor-push";
import {
deletePushAuthRegisteredDevice,
initPushAuthenticatorQRCode
} from "../api/multi-factor-push";
import { getConfigPreferences } from "../api/preference";
import { AlertLevels } from "../models/alert";
import { HttpResponse } from "../models/api";
import { MultiFactorAuthenticationConstants } from "../constants/mfa-constants";
import {
ConfigPreferenceResponseInterface,
PushDeviceMgtConfigInterface
} from "../models/push-authenticator";
import { AppState } from "../store";
import { addAlert } from "../store/actions/global";

/**
Expand All @@ -42,11 +52,86 @@ const usePushAuthenticator = () => {
const [ isLoading, setIsLoading ] = useState<boolean>(false);
const [ isConfigPushAuthenticatorModalOpen, setIsConfigPushAuthenticatorModalOpen ] = useState<boolean>(false);
const [ qrCode, setQrCode ] = useState<string>(null);
const [ isPushDeviceMgtConfigLoading, setIsPushDeviceMgtConfigLoading ] = useState<boolean>(false);
const [ pushDeviceMgtConfig, setPushDeviceMgtConfig ] = useState<PushDeviceMgtConfigInterface>(null);

const { t } = useTranslation();

const translateKey: string = "myAccount:components.mfa.pushAuthenticatorApp.";

// Feature flag that gates multiple device support, exposed as a property of the security feature config.
const isMultipleDeviceSupportEnabled: boolean = useSelector(
(state: AppState) =>
Boolean(state?.config?.ui?.features?.security?.properties?.multiplePushDeviceSupportEnabled)
);

const isMultipleDeviceEnrollmentEnabled: boolean =
isMultipleDeviceSupportEnabled && (pushDeviceMgtConfig?.enableMultipleDeviceEnrollment ?? false);

const deviceLimit: number = isMultipleDeviceEnrollmentEnabled
? (pushDeviceMgtConfig?.maximumDeviceLimit
?? MultiFactorAuthenticationConstants.DEFAULT_MULTIPLE_PUSH_DEVICE_LIMIT)
: MultiFactorAuthenticationConstants.SINGLE_PUSH_DEVICE_LIMIT;

const isDeviceLimitReached: boolean = (registeredDeviceList?.length ?? 0) >= deviceLimit;

useEffect(() => {
if (!isMultipleDeviceSupportEnabled) {
return;
}

setIsPushDeviceMgtConfigLoading(true);

getConfigPreferences([
{
attributeNames: [
MultiFactorAuthenticationConstants.PROPERTY_ENABLE_MULTIPLE_DEVICE_ENROLLMENT,
MultiFactorAuthenticationConstants.PROPERTY_MAXIMUM_PUSH_DEVICE_LIMIT
],
resourceName: MultiFactorAuthenticationConstants.DEVICE_MGT_RESOURCE_NAME,
resourceType: MultiFactorAuthenticationConstants.RESOURCE_TYPE
}
])
.then((response: ConfigPreferenceResponseInterface[]) => {
const resource: ConfigPreferenceResponseInterface | undefined = response?.find(
(item: ConfigPreferenceResponseInterface) =>
item.resourceType === MultiFactorAuthenticationConstants.RESOURCE_TYPE &&
item.resourceName === MultiFactorAuthenticationConstants.DEVICE_MGT_RESOURCE_NAME
);

if (resource) {
const getValue = (name: string): string | undefined =>
resource.attributeNames.find((p) => p.name === name)?.value;

const parsedMaximumDeviceLimit: number = Number.parseInt(
getValue(MultiFactorAuthenticationConstants.PROPERTY_MAXIMUM_PUSH_DEVICE_LIMIT)
?? String(MultiFactorAuthenticationConstants.DEFAULT_MULTIPLE_PUSH_DEVICE_LIMIT),
10
);
const safeMaximumDeviceLimit: number =
Number.isFinite(parsedMaximumDeviceLimit) && parsedMaximumDeviceLimit > 0
? parsedMaximumDeviceLimit
: MultiFactorAuthenticationConstants.DEFAULT_MULTIPLE_PUSH_DEVICE_LIMIT;

setPushDeviceMgtConfig({
enableMultipleDeviceEnrollment:
getValue(MultiFactorAuthenticationConstants.PROPERTY_ENABLE_MULTIPLE_DEVICE_ENROLLMENT) === "true",
maximumDeviceLimit: safeMaximumDeviceLimit
});
}
})
.catch(() => {
dispatch(addAlert({
description: t(translateKey + "notifications.configFetchError.genericError.description"),
level: AlertLevels.ERROR,
message: t(translateKey + "notifications.configFetchError.genericError.message")
}));
})
.finally(() => {
setIsPushDeviceMgtConfigLoading(false);
});
}, [ isMultipleDeviceSupportEnabled ]);

useEffect(() => {
if (registeredDeviceListFetchError && !isRegisteredDeviceListLoading) {
dispatch(addAlert({
Expand All @@ -62,7 +147,11 @@ const usePushAuthenticator = () => {
/**
* Initiate the push authenticator configuration flow.
*/
const initPushAuthenticatorRegFlow = () => {
const initPushAuthenticatorRegFlow = (): void => {
if (isDeviceLimitReached) {
return;
}

setIsLoading(true);

initPushAuthenticatorQRCode()
Expand Down Expand Up @@ -101,7 +190,7 @@ const usePushAuthenticator = () => {
const handlePushAuthenticatorSetupSubmit = (event: React.FormEvent<HTMLFormElement>): void => {
event.preventDefault();
updateRegisteredDeviceList();
// setIsConfigPushAuthenticatorModalOpen(false);
setQrCode(null);
};

/**
Expand All @@ -122,9 +211,9 @@ const usePushAuthenticator = () => {
}
).catch((_err: any) => {
dispatch(addAlert({
description: t(translateKey + "notifications.deleteError.genericError.description"),
description: t(translateKey + "notifications.delete.genericError.description"),
level: AlertLevels.ERROR,
message: t(translateKey + "notifications.deleteError.genericError.message")
message: t(translateKey + "notifications.delete.genericError.message")
}));
}).finally(() => {
setIsLoading(false);
Expand All @@ -133,11 +222,15 @@ const usePushAuthenticator = () => {

return {
deleteRegisteredDevice,
deviceLimit,
handlePushAuthenticatorInitCancel,
handlePushAuthenticatorSetupSubmit,
initPushAuthenticatorRegFlow,
isConfigPushAuthenticatorModalOpen,
isDeviceLimitReached,
isLoading,
isPushDeviceMgtConfigLoading,
isMultipleDeviceEnrollmentEnabled,
isRegisteredDeviceListLoading,
qrCode,
registeredDeviceList,
Expand Down
1 change: 1 addition & 0 deletions apps/myaccount/src/models/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface ServiceResourceEndpointsInterface {
preference: string;
profileSchemas: string;
push: string;
serverConfigPreferences: string;
sessions: string;
otpCodeValidate: string;
token: string;
Expand Down
Loading
Loading