Building passwordless login for Salesforce Experience Cloud

gregheartcloud
9 min readMar 19, 2023

--

Photo by Towfiqu barbhuiya on Unsplash

The problem

As part of my work on Reactforce I am intrigued by complex portal use cases. My projects typically deal with pixel-perfect web sites built with ReactJS and hosted on Salesforce.com (which I have nicknamed “reactforce” until my future trademark or future cease-and-desist letters)

Multi-factor authentication has been one of the newer requirements for Experience Cloud users that I have been building with my clients.

For my current implementation I have been tasked with a cool concept… passwordless login. In this case, email has been approved by the security team as an adequately trusted medium for user authentication.

I have struggled thus far to get Salesforce’s MFA working on Experience Cloud exactly the way I want it. Some of the Salesforce branded screens, including a shameless push to the Salesforce authenticator, are thus far hard for me to bypass with out of the box solutions.

So rather then activating MFA for the external users, I started evaluating passwordless login as a solution because it would require an external system (email) for each login, as opposed to a password AND an email verification. (I know this is technically not MULTI-factor authentication… but if its good enough for Slack its good enough for me!)

Let’s dive into the architecture and see how this works:

The pieces

Passwordless Architecture

Landing Page (Authenticated and Un-authenticated)

  • The landing page is the home page for the Experience Cloud. I use the same landing page for Authenticated and Unauthenticated users.

Login Discovery Page

  • The login discovery page is provided by Salesforce and will capture the user’s email address or phone number and pass it to the registration handlers. This is helpful because it will honor the branding of your Experience Cloud setup.

Registration Handler

  • Salesforce provides a registration handler template to get you about 90% of a working solution. I have made the following changes to get the solution working:
  • I added the profileID that I wanted to be automatically set upon user registration.
  • I added the accountID that I wanted the user contact record to be created under.
  • I created a contact for the user.
  • Helpful reminder… make sure that the owner of the account that is assigned to the users has a role. In my example I had to create a role for my scratch org user as the portal role hierarchy is magically inserted underneath that role upon the creation of a user in that account.
  • Another helpful reminder… make sure that the profile that is assigned to the user is part of the membership of the Experience Cloud. Otherwise the verification emails will not be sent out.

Login Handler

  • This code that was provided by Salesforce needed no changes. Cool.

Email Templates

  • When building Salesforce Experience Cloud solutions, branding is a very important requirement and removing all of the Salesforce branding is an important step for your users and business stakeholders.

Considerations

  • SMS — I was not able to test the SMS version of this solution because I have not yet been able to provision a development environment with the Identity Verification Credits that are necessary to send the SMS messages. When I figure it out I will update this post.

Let’s build it!

Build an Experience Cloud Site

The easiest way I do this is to use a SFDX scratch org with the following org settings in the definition file:

{
"orgName": "Reactforce",
"edition": "Developer",
"hasSampleData": false,
"description": "Scratch Org with Communities Enabled",
"adminEmail": "greg@cloudpremise.com",
"features": ["Communities","SalesforceIdentityForCommunities","ExternalIdentityLogin","IdentityProvisioningFeatures"],
"settings": {
"communitiesSettings": {
"enableNetworksEnabled": true
}
}
}

and then create the scratch org:

sfdx force:org:create -f config/orgs/project-scratch-developer-communities.json --setalias cp-dev --setdefaultusername -d 3

Under Setup → Digital Experiences → All Sites

create a new site. I am using the Visualforce template for simplicity.

I named the site “passwordless”. Now its time to setup the settings under “Administration”

go ahead and activate the site under “Settings”

assign the “Customer Community Login User” as a member of the community

Now its time to setup the login & registration settings

  1. First I set a logo to observe the branding of the solution
  2. I also changed the default color scheme for the same reason
  3. I changed the footer text (same reason)
  4. For Login Page Type I selected “Login Discovery Page”
  5. I clicked the link to allow Salesforce to create the default handler.
  6. I used my scratch org system admin for the “Execute Login As”
  7. Next I clicked “Allow customers and partners to self-register”
  8. The registration page type should be set to “Configurable Self-Reg Page”
  9. I am only requiring Email in the User fields.
  10. Once again click the link for the default Self-Registration handler
  11. Once again I am running that class under the scratch org system admin user.
  12. Verification Method = Email
  13. Click Save which creates the registration handlers needed.

Let’s see what the Login Process looks like thus far… I used the link from the Settings tab and opened it in another browser (or incognito etc)

Notice how the branding of the passwordless login page honors our settings. Looks good.

Update the system generated apex classes

Here is the code I used to get this working (note… most of this is system generated except where I have added comments):

//
global class AutocreatedConfigSelfReg1678949608946 implements Auth.ConfigurableSelfRegHandler {

private final Long CURRENT_TIME = Datetime.now().getTime();
private final String[] UPPERCASE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
private final String[] LOWERCASE_CHARS = 'abcdefghijklmnopqrstuvwxyz'.split('');
private final String[] NUMBER_CHARS = '1234567890'.split('');
private final String[] SPECIAL_CHARS = '!#$%-_=+<>'.split('');

// This method is called once after verification (if any was configured)
// This method should create a user and insert it
// Password can be null
// Return null or throw an exception to fail creation
global Id createUser(Id accountId, Id profileId, Map<SObjectField, String> registrationAttributes, String password) {
/*Add Logging*/
System.debug('createUser()');
System.debug('accountId:'+accountId);
System.debug('profileId:'+profileId);
System.debug('registrationAttributes:'+registrationAttributes);
System.debug('password:'+password);

User u = new User();
/*Set the default profileId to the Customer Community Login User
*this Id will of course need to be changed to your own org's implementation*/
u.ProfileId = '00e7e000000ZYhs';

for (SObjectField field : registrationAttributes.keySet()) {
String value = registrationAttributes.get(field);
u.put(field, value);
}

u = handleUnsetRequiredFields(u);

/*Set the ContactId for the User*/
u.ContactId = generateContact(u, accountId);

if (String.isBlank(password)) {
password = generateRandomPassword();
}
System.debug('password:'+password);
Site.validatePassword(u, password, password);
if (u.contactId == null) {
return Site.createExternalUser(u, accountId, password);
}
u.languagelocalekey = UserInfo.getLocale();
u.localesidkey = UserInfo.getLocale();
u.emailEncodingKey = 'UTF-8';
u.timeZoneSidKey = UserInfo.getTimezone().getID();

System.debug('u:'+u);
insert u;
System.setPassword(u.Id, password);
return u.id;
}

// Method to autogenerate a password if one was not passed in
// By setting a password for a user, we won't send a welcome email to set the password
private String generateRandomPassword() {
String[] characters = new List<String>(UPPERCASE_CHARS);
characters.addAll(LOWERCASE_CHARS);
characters.addAll(NUMBER_CHARS);
characters.addAll(SPECIAL_CHARS);
String newPassword = '';
Boolean needsUpper = true, needsLower = true, needsNumber = true, needsSpecial = true;
while (newPassword.length() < 50) {
Integer randomInt = generateRandomInt(characters.size());
String c = characters[randomInt];
if (needsUpper && c.isAllUpperCase()) {
needsUpper = false;
} else if (needsLower && c.isAllLowerCase()) {
needsLower = false;
} else if (needsNumber && c.isNumeric()) {
needsNumber = false;
} else if (needsSpecial && !c.isAlphanumeric()) {
needsSpecial = false;
}
newPassword += c;
}
newPassword = addMissingPasswordRequirements(newPassword, needsLower, needsUpper, needsNumber, needsSpecial);
return newPassword;
}

private String addMissingPasswordRequirements(String password, Boolean addLowerCase, Boolean addUpperCase, Boolean addNumber, Boolean addSpecial) {
if (addLowerCase) {
password += LOWERCASE_CHARS[generateRandomInt(LOWERCASE_CHARS.size())];
}
if (addUpperCase) {
password += UPPERCASE_CHARS[generateRandomInt(UPPERCASE_CHARS.size())];
}
if (addNumber) {
password += NUMBER_CHARS[generateRandomInt(NUMBER_CHARS.size())];
}
if (addSpecial) {
password += SPECIAL_CHARS[generateRandomInt(SPECIAL_CHARS.size())];
}
return password;
}

// Generates a random number from 0 up to, but not including, max.
private Integer generateRandomInt(Integer max) {
return Math.mod(Math.abs(Crypto.getRandomInteger()), max);
}

// Loops over required fields that were not passed in to set to some default value
private User handleUnsetRequiredFields(User u) {
if (String.isBlank(u.LastName)){
u.LastName = generateLastName();
}
if (String.isBlank(u.Username)) {
u.Username = generateUsername();
}
if (String.isBlank(u.Email)) {
u.Email = generateEmail();
}
if (String.isBlank(u.Alias)) {
u.Alias = generateAlias();
}
if (String.isBlank(u.CommunityNickname)) {
u.CommunityNickname = generateCommunityNickname();
}
return u;
}

// Method to construct a contact for a user
private Id generateContact(User u, Id accountId) {
Contact cont = new Contact();
cont.FirstName=u.FirstName;
cont.LastName=u.LastName;
cont.AccountId=accountId;
System.debug('cont:'+cont);
insert cont;
return cont.Id;

}

// Default implementation to try to provide uniqueness
private String generateAlias() {
String timeString = String.valueOf(CURRENT_TIME);
return timeString.substring(timeString.length() - 8);
}

// Default implementation to try to provide uniqueness
private String generateLastName() {
return 'ExternalUser' + CURRENT_TIME;
}

// Default implementation to try to provide uniqueness
private String generateUsername() {
return 'externaluser' + CURRENT_TIME + '@company.com';
}

// Default implementation to try to provide uniqueness
private String generateEmail() {
return 'externaluser' + CURRENT_TIME + '@company.com';
}

// Default implementation to try to provide uniqueness
private String generateCommunityNickname() {
return 'ExternalUser' + CURRENT_TIME;
}
}

Before we can test this, we need to allow standard profiles to be used. This setting can be found under Setup → Digital Experiences → Settings

We also need to create the Account that the contact will be assigned to:

And set the account under the passwordless Experince Cloud Settings:

Finally… set a role of the owner of that account (in this case my scratch org system admin)

Ok lets test user sign up!

Navigate to the community home page and observe the login form.

Click to sign up and add your unique customer email:

When you click signup, you will receive an email verification from Salesforce, branded for the community:

Verify the user:

If everything above was done correctly, upon clicking the verification button the following happes:

  1. The contact record is created and linked to the account specified in the setup
  2. The user record is created and linked to the contact and profile specified in the registration handler
  3. The use is logged in
  4. Everyone thinks you are awesome.

Finally, lets test the login flow

Return to the login page and enter the now-verified email address. Click Login.

Check your email and get the email verification code:

Click Verify. Celebrate indeed being awesome as you have removed passwords from your external users.

--

--