Skip to content

Commit

Permalink
feat: functionality to switch mfa on and off HP-2472 (#404)
Browse files Browse the repository at this point in the history
* feat: functionality to switch mfa on and off
  • Loading branch information
Riippi authored Jan 28, 2025
1 parent 01ae1f3 commit 78611cc
Show file tree
Hide file tree
Showing 24 changed files with 800 additions and 16 deletions.
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ REACT_APP_PROFILE_AUDIENCE="https://api.hel.fi/auth/helsinkiprofiledev"
REACT_APP_PROFILE_GRAPHQL="https://profile-api.dev.hel.ninja/graphql/"
REACT_APP_OIDC_SCOPE="openid profile https://api.hel.fi/auth/helsinkiprofiledev"
REACT_APP_PROFILE_BE_GDPR_CLIENT_ID="https://api.hel.fi/auth/helsinkiprofiledev"
REACT_APP_MFA_ENABLED=true
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ REACT_APP_KEYCLOAK_AUTHORITY="https://tunnistus.test.hel.ninja/auth/realms/helsi
REACT_APP_MATOMO_URL_BASE="test"
REACT_APP_MATOMO_SITE_ID="test123"
REACT_APP_MATOMO_ENABLED=false
REACT_APP_MFA_ENABLED=true
11 changes: 11 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import LoginSSO from './auth/components/loginsso/LoginSSO';
import MatomoTracker from './common/matomo/MatomoTracker';
import { MatomoProvider } from './common/matomo/matomo-context';
import PasswordChangeCallback from './passwordChange/PasswordChangeCallback';
import OtpConfigurationCallback from './otpConfiguration/OtpConfigurationCallback';

countries.registerLocale(fi);
countries.registerLocale(en);
Expand Down Expand Up @@ -95,6 +96,16 @@ function App(): React.ReactElement {
path="/password-change-callback"
component={PasswordChangeCallback}
></Route>
<Route
path="/otp-configuration-callback"
component={OtpConfigurationCallback}
></Route>
<Route
path="/delete-credential-callback"
render={routeProps => (
<OtpConfigurationCallback {...routeProps} action="delete" />
)}
/>
<Route path="/login">
<Login />
</Route>
Expand Down
16 changes: 16 additions & 0 deletions src/auth/__tests__/useAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,20 @@ describe('useAuth', () => {
const passwordChange = result.current.changePassword();
await expect(passwordChange).resolves.not.toThrow();
});

it('should enable MFA', async () => {
mockOidcClient.getUserManager().signinRedirect.mockResolvedValue(true);

const { result } = renderHook(() => useAuth());
const initTOTP = result.current.initiateTOTP();
await expect(initTOTP).resolves.not.toThrow();
});

it('should disable MFA', async () => {
mockOidcClient.getUserManager().signinRedirect.mockResolvedValue(true);

const { result } = renderHook(() => useAuth());
const disableTOTP = result.current.disableTOTP('111');
await expect(disableTOTP).resolves.not.toThrow();
});
});
45 changes: 44 additions & 1 deletion src/auth/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useOidcClient } from 'hds-react';

const origin = window.location.origin;

const NETWORK_ERROR = 'Network Error';

const useAuth = () => {
const oidcClient = useOidcClient();
const userManager = oidcClient.getUserManager();
Expand All @@ -26,7 +28,46 @@ const useAuth = () => {
})
.catch(error => {
success = false;
if (error.message !== 'Network Error') {
if (error.message !== NETWORK_ERROR) {
Sentry.captureException(error);
}
});
return success ? Promise.resolve() : Promise.reject();
};

const initiateTOTP = async (): Promise<void> => {
let success = true;
await userManager
.signinRedirect({
ui_locales: i18n.language,
redirect_uri: `${origin}/otp-configuration-callback`, // otp-configuration-callback
extraQueryParams: {
kc_action: 'CONFIGURE_TOTP',
},
})
.catch(error => {
success = false;
if (error.message !== NETWORK_ERROR) {
Sentry.captureException(error);
}
});
return success ? Promise.resolve() : Promise.reject();
};

const disableTOTP = async (id: string | null): Promise<void> => {
let success = true;

await userManager
.signinRedirect({
ui_locales: i18n.language,
redirect_uri: `${origin}/delete-credential-callback`, // otp-configuration-callback
extraQueryParams: {
kc_action: 'delete_credential:' + id,
},
})
.catch(error => {
success = false;
if (error.message !== NETWORK_ERROR) {
Sentry.captureException(error);
}
});
Expand All @@ -38,6 +79,8 @@ const useAuth = () => {
login: oidcClient.login,
logout,
changePassword,
initiateTOTP,
disableTOTP,
};
};

Expand Down
10 changes: 9 additions & 1 deletion src/common/test/myProfileMocking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,15 @@ export const getMyProfile = (): ProfileRoot => ({
__typename: 'PhoneNodeConnection',
},
verifiedPersonalInformation: null,
loginMethods: [LoginMethodType.PASSWORD],
availableLoginMethods: [
{
__typename: 'LoginMethodNode',
method: LoginMethodType.PASSWORD,
createdAt: '2025-01-24T11:27:45.637Z',
credentialId: null,
userLabel: null,
},
],
__typename: 'ProfileNode',
},
});
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const config = {
tunnistamoGdprClientId: window._env_.REACT_APP_PROFILE_BE_GDPR_CLIENT_ID,
keycloakGdprClientId: window._env_.REACT_APP_KEYCLOAK_GDPR_CLIENT_ID,
keycloakAuthority: window._env_.REACT_APP_KEYCLOAK_AUTHORITY,
mfa: window._env_.REACT_APP_MFA_ENABLED === 'true',
errorPagePath: '/error',
cookiePagePath: '/cookies',
autoSSOLoginPath: '/loginsso',
Expand Down
6 changes: 4 additions & 2 deletions src/graphql/generatedTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,8 @@ export type MyProfileQueryEmailsEdgesNodeFragment = { readonly __typename: 'Emai

export type MyProfileQueryPhonesEdgesNodeFragment = { readonly __typename: 'PhoneNode', readonly primary: boolean, readonly id: string, readonly phone: string, readonly phoneType: PhoneType | null };

export type MyLoginMethodNodeFragment = { readonly __typename: 'LoginMethodNode', readonly method: LoginMethodType, readonly createdAt: any | null, readonly credentialId: string | null, readonly userLabel: string | null };

export type MyProfileQueryAddressesEdgesFragment = { readonly __typename: 'AddressNodeEdge', readonly node: { readonly __typename: 'AddressNode', readonly primary: boolean, readonly id: string, readonly address: string, readonly postalCode: string, readonly city: string, readonly countryCode: string, readonly addressType: AddressType | null } | null };

export type MyProfileQueryEmailsEdgesFragment = { readonly __typename: 'EmailNodeEdge', readonly node: { readonly __typename: 'EmailNode', readonly primary: boolean, readonly id: string, readonly email: string, readonly emailType: EmailType | null } | null };
Expand All @@ -533,12 +535,12 @@ export type MyProfileQueryEmailsFragment = { readonly __typename: 'EmailNodeConn

export type MyProfileQueryPhonesFragment = { readonly __typename: 'PhoneNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'PhoneNodeEdge', readonly node: { readonly __typename: 'PhoneNode', readonly primary: boolean, readonly id: string, readonly phone: string, readonly phoneType: PhoneType | null } | null } | null> };

export type MyProfileQueryFragment = { readonly __typename: 'ProfileNode', readonly id: string, readonly firstName: string, readonly lastName: string, readonly nickname: string, readonly language: Language | null, readonly loginMethods: ReadonlyArray<LoginMethodType | null> | null, readonly primaryAddress: { readonly __typename: 'AddressNode', readonly id: string, readonly primary: boolean, readonly address: string, readonly postalCode: string, readonly city: string, readonly countryCode: string, readonly addressType: AddressType | null } | null, readonly addresses: { readonly __typename: 'AddressNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'AddressNodeEdge', readonly node: { readonly __typename: 'AddressNode', readonly primary: boolean, readonly id: string, readonly address: string, readonly postalCode: string, readonly city: string, readonly countryCode: string, readonly addressType: AddressType | null } | null } | null> } | null, readonly primaryEmail: { readonly __typename: 'EmailNode', readonly id: string, readonly email: string, readonly primary: boolean, readonly emailType: EmailType | null } | null, readonly emails: { readonly __typename: 'EmailNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'EmailNodeEdge', readonly node: { readonly __typename: 'EmailNode', readonly primary: boolean, readonly id: string, readonly email: string, readonly emailType: EmailType | null } | null } | null> } | null, readonly primaryPhone: { readonly __typename: 'PhoneNode', readonly id: string, readonly phone: string, readonly primary: boolean, readonly phoneType: PhoneType | null } | null, readonly phones: { readonly __typename: 'PhoneNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'PhoneNodeEdge', readonly node: { readonly __typename: 'PhoneNode', readonly primary: boolean, readonly id: string, readonly phone: string, readonly phoneType: PhoneType | null } | null } | null> } | null, readonly verifiedPersonalInformation: { readonly __typename: 'VerifiedPersonalInformationNode', readonly firstName: string, readonly lastName: string, readonly givenName: string, readonly nationalIdentificationNumber: string, readonly municipalityOfResidence: string, readonly municipalityOfResidenceNumber: string, readonly permanentAddress: { readonly __typename: 'VerifiedPersonalInformationAddressNode', readonly streetAddress: string, readonly postalCode: string, readonly postOffice: string } | null, readonly permanentForeignAddress: { readonly __typename: 'VerifiedPersonalInformationForeignAddressNode', readonly streetAddress: string, readonly additionalAddress: string, readonly countryCode: string } | null } | null };
export type MyProfileQueryFragment = { readonly __typename: 'ProfileNode', readonly id: string, readonly firstName: string, readonly lastName: string, readonly nickname: string, readonly language: Language | null, readonly availableLoginMethods: ReadonlyArray<{ readonly __typename: 'LoginMethodNode', readonly method: LoginMethodType, readonly createdAt: any | null, readonly credentialId: string | null, readonly userLabel: string | null } | null> | null, readonly primaryAddress: { readonly __typename: 'AddressNode', readonly id: string, readonly primary: boolean, readonly address: string, readonly postalCode: string, readonly city: string, readonly countryCode: string, readonly addressType: AddressType | null } | null, readonly addresses: { readonly __typename: 'AddressNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'AddressNodeEdge', readonly node: { readonly __typename: 'AddressNode', readonly primary: boolean, readonly id: string, readonly address: string, readonly postalCode: string, readonly city: string, readonly countryCode: string, readonly addressType: AddressType | null } | null } | null> } | null, readonly primaryEmail: { readonly __typename: 'EmailNode', readonly id: string, readonly email: string, readonly primary: boolean, readonly emailType: EmailType | null } | null, readonly emails: { readonly __typename: 'EmailNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'EmailNodeEdge', readonly node: { readonly __typename: 'EmailNode', readonly primary: boolean, readonly id: string, readonly email: string, readonly emailType: EmailType | null } | null } | null> } | null, readonly primaryPhone: { readonly __typename: 'PhoneNode', readonly id: string, readonly phone: string, readonly primary: boolean, readonly phoneType: PhoneType | null } | null, readonly phones: { readonly __typename: 'PhoneNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'PhoneNodeEdge', readonly node: { readonly __typename: 'PhoneNode', readonly primary: boolean, readonly id: string, readonly phone: string, readonly phoneType: PhoneType | null } | null } | null> } | null, readonly verifiedPersonalInformation: { readonly __typename: 'VerifiedPersonalInformationNode', readonly firstName: string, readonly lastName: string, readonly givenName: string, readonly nationalIdentificationNumber: string, readonly municipalityOfResidence: string, readonly municipalityOfResidenceNumber: string, readonly permanentAddress: { readonly __typename: 'VerifiedPersonalInformationAddressNode', readonly streetAddress: string, readonly postalCode: string, readonly postOffice: string } | null, readonly permanentForeignAddress: { readonly __typename: 'VerifiedPersonalInformationForeignAddressNode', readonly streetAddress: string, readonly additionalAddress: string, readonly countryCode: string } | null } | null };

export type MyProfileQueryVariables = Exact<{ [key: string]: never; }>;


export type MyProfileQuery = { readonly myProfile: { readonly __typename: 'ProfileNode', readonly id: string, readonly firstName: string, readonly lastName: string, readonly nickname: string, readonly language: Language | null, readonly loginMethods: ReadonlyArray<LoginMethodType | null> | null, readonly primaryAddress: { readonly __typename: 'AddressNode', readonly id: string, readonly primary: boolean, readonly address: string, readonly postalCode: string, readonly city: string, readonly countryCode: string, readonly addressType: AddressType | null } | null, readonly addresses: { readonly __typename: 'AddressNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'AddressNodeEdge', readonly node: { readonly __typename: 'AddressNode', readonly primary: boolean, readonly id: string, readonly address: string, readonly postalCode: string, readonly city: string, readonly countryCode: string, readonly addressType: AddressType | null } | null } | null> } | null, readonly primaryEmail: { readonly __typename: 'EmailNode', readonly id: string, readonly email: string, readonly primary: boolean, readonly emailType: EmailType | null } | null, readonly emails: { readonly __typename: 'EmailNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'EmailNodeEdge', readonly node: { readonly __typename: 'EmailNode', readonly primary: boolean, readonly id: string, readonly email: string, readonly emailType: EmailType | null } | null } | null> } | null, readonly primaryPhone: { readonly __typename: 'PhoneNode', readonly id: string, readonly phone: string, readonly primary: boolean, readonly phoneType: PhoneType | null } | null, readonly phones: { readonly __typename: 'PhoneNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'PhoneNodeEdge', readonly node: { readonly __typename: 'PhoneNode', readonly primary: boolean, readonly id: string, readonly phone: string, readonly phoneType: PhoneType | null } | null } | null> } | null, readonly verifiedPersonalInformation: { readonly __typename: 'VerifiedPersonalInformationNode', readonly firstName: string, readonly lastName: string, readonly givenName: string, readonly nationalIdentificationNumber: string, readonly municipalityOfResidence: string, readonly municipalityOfResidenceNumber: string, readonly permanentAddress: { readonly __typename: 'VerifiedPersonalInformationAddressNode', readonly streetAddress: string, readonly postalCode: string, readonly postOffice: string } | null, readonly permanentForeignAddress: { readonly __typename: 'VerifiedPersonalInformationForeignAddressNode', readonly streetAddress: string, readonly additionalAddress: string, readonly countryCode: string } | null } | null } | null };
export type MyProfileQuery = { readonly myProfile: { readonly __typename: 'ProfileNode', readonly id: string, readonly firstName: string, readonly lastName: string, readonly nickname: string, readonly language: Language | null, readonly availableLoginMethods: ReadonlyArray<{ readonly __typename: 'LoginMethodNode', readonly method: LoginMethodType, readonly createdAt: any | null, readonly credentialId: string | null, readonly userLabel: string | null } | null> | null, readonly primaryAddress: { readonly __typename: 'AddressNode', readonly id: string, readonly primary: boolean, readonly address: string, readonly postalCode: string, readonly city: string, readonly countryCode: string, readonly addressType: AddressType | null } | null, readonly addresses: { readonly __typename: 'AddressNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'AddressNodeEdge', readonly node: { readonly __typename: 'AddressNode', readonly primary: boolean, readonly id: string, readonly address: string, readonly postalCode: string, readonly city: string, readonly countryCode: string, readonly addressType: AddressType | null } | null } | null> } | null, readonly primaryEmail: { readonly __typename: 'EmailNode', readonly id: string, readonly email: string, readonly primary: boolean, readonly emailType: EmailType | null } | null, readonly emails: { readonly __typename: 'EmailNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'EmailNodeEdge', readonly node: { readonly __typename: 'EmailNode', readonly primary: boolean, readonly id: string, readonly email: string, readonly emailType: EmailType | null } | null } | null> } | null, readonly primaryPhone: { readonly __typename: 'PhoneNode', readonly id: string, readonly phone: string, readonly primary: boolean, readonly phoneType: PhoneType | null } | null, readonly phones: { readonly __typename: 'PhoneNodeConnection', readonly edges: ReadonlyArray<{ readonly __typename: 'PhoneNodeEdge', readonly node: { readonly __typename: 'PhoneNode', readonly primary: boolean, readonly id: string, readonly phone: string, readonly phoneType: PhoneType | null } | null } | null> } | null, readonly verifiedPersonalInformation: { readonly __typename: 'VerifiedPersonalInformationNode', readonly firstName: string, readonly lastName: string, readonly givenName: string, readonly nationalIdentificationNumber: string, readonly municipalityOfResidence: string, readonly municipalityOfResidenceNumber: string, readonly permanentAddress: { readonly __typename: 'VerifiedPersonalInformationAddressNode', readonly streetAddress: string, readonly postalCode: string, readonly postOffice: string } | null, readonly permanentForeignAddress: { readonly __typename: 'VerifiedPersonalInformationForeignAddressNode', readonly streetAddress: string, readonly additionalAddress: string, readonly countryCode: string } | null } | null } | null };

export type NameQueryVariables = Exact<{ [key: string]: never; }>;

Expand Down
13 changes: 12 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -297,5 +297,16 @@
"changeProfilePassword ": {
"explanationForStrongAuthentication": "You can only change your password if you are strongly identified. Log out and log in again using Suomi.fi authentication to change your password."
},
"userGuide": "Helsinki profile guide"
"userGuide": "Helsinki profile guide",
"mfa": {
"title": "Two-step authentication",
"enable": "Enable",
"disable": "Disable",
"dateEnabled": "Enabled",
"disabled": "Not enabled",
"enableSuccess": "",
"enableFailed": "",
"disableSuccess": "",
"disableFailed": ""
}
}
13 changes: 12 additions & 1 deletion src/i18n/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -297,5 +297,16 @@
"changeProfilePassword ": {
"explanationForStrongAuthentication": "Voit vaihtaa salasanan vain, jos olet vahvasti tunnistautunut. Kirjaudu ulos ja takaisin sisään käyttäen Suomi.fi-tunnistautumista vaihtaaksesi salasanasi."
},
"userGuide": "Helsinki-profiilin ohje"
"userGuide": "Helsinki-profiilin ohje",
"mfa": {
"title": "Kaksivaiheinen tunnistautuminen",
"enable": "Ota käyttöön",
"disable": "Poista käytöstä",
"dateEnabled": "Otettu käyttöön",
"disabled": "Ei käytössä",
"enableSuccess": "Käyttöönotto onnistui.",
"enableFailed": "Käyttöönotto epäonnistui. Yritä uudelleen.",
"disableSuccess": "Käytöstä poisto onnistui.",
"disableFailed": "Käytöstä poisto epäonnistui. Yritä uudelleen."
}
}
13 changes: 12 additions & 1 deletion src/i18n/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -297,5 +297,16 @@
"changeProfilePassword ": {
"explanationForStrongAuthentication": "Du kan bara ändra ditt lösenord om du är starkt identifierad. Logga ut och logga in igen med Suomi.fi-autentisering för att ändra ditt lösenord."
},
"userGuide": "Helsingforsprofilens hjälp"
"userGuide": "Helsingforsprofilens hjälp",
"mfa": {
"title": "Tvåstegsautentisering",
"enable": "Aktivera",
"disable": "Inaktivera",
"dateEnabled": "Aktiverades",
"disabled": "Inte aktiverad",
"enableSuccess": "",
"enableFailed": "",
"disableSuccess": "",
"disableFailed": ""
}
}
Loading

0 comments on commit 78611cc

Please sign in to comment.