as1984
as1984

Reputation: 37

Azure AD B2C - Forgot Password User Journey - Don't Allow old password?

I'm building an Azure AD B2C configuration based on custom policies. Sign in, profile edit, password change, etc. are already working as wanted.

But currently I'm struggling with the password forgot policy. I want to achieve that the new password does not equal to old one. Google and the Microsoft docs always give me examples for password changes. When I change the password, I have to enter the old one and the new one. Then I'm able to compare the old and the new one. For example like the way discribed here

But when a user has forgotten his password, then he is - of course - not able to enter the old password to compare it with the new one.

Is there any way to build a real password forgot policy without entering the old password but nevertheless ensure that the new password does not equal the old password?

Thanks in advance!

Alex

Upvotes: 2

Views: 1508

Answers (2)

iliosana
iliosana

Reputation: 51

I was having the same problem and the answer from Jas Suri helped me a lot. However, I had some problems getting it to work. So, that's why I am sharing my final solution in case someone else is facing the same problem.

I have used the following as a base: https://github.com/azure-ad-b2c/samples/tree/master/policies/password-reset-not-last-password/policy but made some adjustments. Here is what I had to add to my existing policy to make it work:

ClaimSchema:

These claims will be used later.

    <ClaimsSchema>
      <ClaimType Id="SamePassword">
        <DisplayName>samePassword</DisplayName>
        <DataType>boolean</DataType>
        <UserHelpText />
      </ClaimType>
      <ClaimType Id="resetPasswordObjectId">
        <DisplayName>User's Object ID</DisplayName>
        <DataType>string</DataType>
        <DefaultPartnerClaimTypes>
          <Protocol Name="OAuth2" PartnerClaimType="oid" />
          <Protocol Name="OpenIdConnect" PartnerClaimType="oid" />
          <Protocol Name="SAML2" PartnerClaimType="http://schemas.microsoft.com/identity/claims/objectidentifier" />
        </DefaultPartnerClaimTypes>
        <UserHelpText>Object identifier (ID) of the user object in Azure AD.</UserHelpText>
      </ClaimType>
    </ClaimsSchema>

ClaimsTransformation:

The first claim transformation checks whether resetPasswordObjectId is set, if not the previous attempted login with the new password apparently didn't work and thus the newPassword is not the same as the old / current password. The second claim transformation checks whether the claim SamePassword is equal to false. If not it throws an error and 'You can't use an old password' is displayed. More information on this later.

 <ClaimsTransformations>
  <ClaimsTransformation Id="CheckPasswordEquivalence" TransformationMethod="DoesClaimExist">
    <InputClaims>
      <InputClaim ClaimTypeReferenceId="resetPasswordObjectId" TransformationClaimType="inputClaim" />
    </InputClaims>
    <OutputClaims>
      <OutputClaim ClaimTypeReferenceId="SamePassword" TransformationClaimType="outputClaim" />
    </OutputClaims>
  </ClaimsTransformation>
  <ClaimsTransformation Id="AssertSamePasswordIsFalse" TransformationMethod="AssertBooleanClaimIsEqualToValue">
    <InputClaims>
      <InputClaim ClaimTypeReferenceId="SamePassword" TransformationClaimType="inputClaim" />
    </InputClaims>
    <InputParameters>
      <InputParameter Id="valueToCompareTo" DataType="boolean" Value="false" />
    </InputParameters>
  </ClaimsTransformation>
</ClaimsTransformations>

ClaimsProvider:

The overall idea is to use the newly entered password and attempt to login the user. In case the login is successful, the newPassword is the same as the old / current password and is not allowed. When logging in, we create an output claim and call it resetPasswordObjectId which is either unset or equal to the object id of the logged in user. We then check whether the resetPasswordObjectId exists (done in the claims transformation part), if not the newPassword can be used as it is not the same as the old / current password.

To display the correct error message in case the user entered the old password, we need to override UserMessageIfClaimsTransformationBooleanValueIsNotEqual in the TrustFrameworkLocalization.xml in the <LocalizedResources Id="api.localaccountpasswordreset.en"> like this <LocalizedString ElementType="ErrorMessage" StringId="UserMessageIfClaimsTransformationBooleanValueIsNotEqual">You must not use your old password.</LocalizedString>.

When using the claim provider below, ensure to replace all parts marked with a TODO.

   <ClaimsProviders>
<ClaimsProvider>
  <DisplayName>Password Reset without same password</DisplayName>
  <TechnicalProfiles>
    <TechnicalProfile Id="login-NonInteractive-PasswordChange">
      <DisplayName>Local Account SignIn</DisplayName>
      <Protocol Name="OpenIdConnect" />
      <Metadata>
        <Item Key="UserMessageIfClaimsPrincipalDoesNotExist">We can't seem to find your account</Item>
        <Item Key="UserMessageIfInvalidPassword">Your password is incorrect</Item>
        <Item Key="UserMessageIfOldPasswordUsed">Looks like you used an old password</Item>
        <Item Key="ProviderName">https://sts.windows.net/</Item>
        <!-- TODO  replace YOUR-TENANT-ID -->
        <Item Key="METADATA">https://login.microsoftonline.com/YOUR-TENANT.onmicrosoft.com/.well-known/openid-configuration</Item>
        <!-- TODO replace YOUR-TENANT-ID -->
        <Item Key="authorization_endpoint">https://login.microsoftonline.com/YOUR-TENANT.onmicrosoft.com/oauth2/token</Item>
        <Item Key="response_types">id_token</Item>
        <Item Key="response_mode">query</Item>
        <Item Key="scope">email openid</Item>
        <!-- TODO ensure this line is commented out-->
        <!-- <Item Key="grant_type">password</Item> -->
        <Item Key="UsePolicyInRedirectUri">false</Item>
        <Item Key="HttpBinding">POST</Item>
        <!-- TODO -->
        <!-- ProxyIdentityExperienceFramework application / client id -->
        <Item Key="client_id">YOUR-PROXY-CLIENT-ID</Item>
        <!-- Native App -->
        <!-- TODO -->
        <!-- IdentityExperienceFramework application / client id -->
        <Item Key="IdTokenAudience">YOUR-IDENTITY-CLIENT-ID</Item>
        <!-- Web Api -->
      </Metadata>
      <InputClaims>
        <InputClaim ClaimTypeReferenceId="signInName" PartnerClaimType="username" Required="true" />
        <!-- INFO: replaced oldPassword with newPassword, that way we try logging in with the new password. If the login is successful, we know the newPassword is the same as the old / current password-->
        <InputClaim ClaimTypeReferenceId="newPassword" PartnerClaimType="password" Required="true" />
        <InputClaim ClaimTypeReferenceId="grant_type" DefaultValue="password" />
        <InputClaim ClaimTypeReferenceId="scope" DefaultValue="openid" />
        <InputClaim ClaimTypeReferenceId="nca" PartnerClaimType="nca" DefaultValue="1" />
        <!-- TODO -->
        <!-- ProxyIdentityExperienceFramework application / client id -->
        <InputClaim ClaimTypeReferenceId="client_id" DefaultValue="YOUR-PROXY-CLIENT-ID" />
        <!-- TODO -->
        <!-- IdentityExperienceFramework application / client id -->
        <InputClaim ClaimTypeReferenceId="resource_id" PartnerClaimType="resource" DefaultValue="YOUR-IDENTITY-CLIENT-ID" />
      </InputClaims>
      <OutputClaims>
        <!-- INFO: assign the objectId (oid) to resetPasswordObjectId, since the claim objectId might already be set. In that case there would be no way of knowing whether it was set due to the attempted login with the newPassword-->
        <OutputClaim ClaimTypeReferenceId="resetPasswordObjectId" PartnerClaimType="oid" />
        <OutputClaim ClaimTypeReferenceId="tenantId" PartnerClaimType="tid" />
        <OutputClaim ClaimTypeReferenceId="givenName" PartnerClaimType="given_name" />
        <OutputClaim ClaimTypeReferenceId="surName" PartnerClaimType="family_name" />
        <OutputClaim ClaimTypeReferenceId="displayName" PartnerClaimType="name" />
        <OutputClaim ClaimTypeReferenceId="userPrincipalName" PartnerClaimType="upn" />
        <OutputClaim ClaimTypeReferenceId="authenticationSource" DefaultValue="localAccountAuthentication" />
      </OutputClaims>
    </TechnicalProfile>
    <!--Logic to check new password is not the same as old password
                    Validates old password before writing new password-->
    <TechnicalProfile Id="LocalAccountWritePasswordUsingObjectId">
      <DisplayName>Reset password</DisplayName>
      <Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.SelfAssertedAttributeProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
      <Metadata>
        <Item Key="ContentDefinitionReferenceId">api.localaccountpasswordreset</Item>
        <!-- set in the TrustFrameworkLocalization.xml  -->
        <!-- <Item Key="UserMessageIfClaimsTransformationBooleanValueIsNotEqual">You must not use your old password.</Item> -->
      </Metadata>
      <CryptographicKeys>
        <Key Id="issuer_secret" StorageReferenceId="B2C_1A_TokenSigningKeyContainer" />
      </CryptographicKeys>
      <OutputClaims>
        <OutputClaim ClaimTypeReferenceId="newPassword" Required="true" />
        <OutputClaim ClaimTypeReferenceId="reenterPassword" Required="true" />
      </OutputClaims>
      <ValidationTechnicalProfiles>
        <ValidationTechnicalProfile ReferenceId="login-NonInteractive-PasswordChange" ContinueOnError="true" />
        <ValidationTechnicalProfile ReferenceId="ComparePasswords" />
        <ValidationTechnicalProfile ReferenceId="AAD-UserWritePasswordUsingObjectId">
          <Preconditions>
            <Precondition Type="ClaimEquals" ExecuteActionsIf="true">
              <Value>SamePassword</Value>
              <Value>True</Value>
              <Action>SkipThisValidationTechnicalProfile</Action>
            </Precondition>
          </Preconditions>
        </ValidationTechnicalProfile>
      </ValidationTechnicalProfiles>
    </TechnicalProfile>
    <!-- Runs claimsTransformations to make sure new and old passwords differ -->
    <TechnicalProfile Id="ComparePasswords">
      <DisplayName>Compare Email And Verify Email</DisplayName>
      <Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.ClaimsTransformationProtocolProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
      <OutputClaims>
        <OutputClaim ClaimTypeReferenceId="SamePassword" />
      </OutputClaims>
      <OutputClaimsTransformations>
        <OutputClaimsTransformation ReferenceId="CheckPasswordEquivalence" />
        <OutputClaimsTransformation ReferenceId="AssertSamePasswordIsFalse" />
      </OutputClaimsTransformations>
    </TechnicalProfile>
  </TechnicalProfiles>
</ClaimsProvider>

Upvotes: 3

Jas Suri - MSFT
Jas Suri - MSFT

Reputation: 11335

You can do it with some logic with Validation Technical profiles:

  1. Call login-noninteractive with continueOnError=true
  2. Call a claimTransform to generate a boolean if a claim (like objectId) is null
  3. Use the boolean for the proceeding logic, lets call it pwdIsLastPwd
  4. Call a claimTransform to assert pwdIsLastPwd = false
  5. If it is true, throw an error - "you cannot use this password" using the claimTransform error handler
  6. Continue with the rest of reset password flow

References:

  1. https://learn.microsoft.com/en-us/azure/active-directory-b2c/validation-technical-profile#validationtechnicalprofiles
  2. Call Claim transform from VTP, Boolean ClaimTransform check if claim exists
  3. Assert boolean is true/false
  4. "The UserMessageIfClaimsTransformationBooleanValueIsNotEqual self-asserted technical profile metadata controls the error message that the technical profile presents to the user. "

Upvotes: 0

Related Questions