Authentication-flows-js is a middleware for express-based web application, that implements all authentication/password flows: authentication, create account, forgot password and more. Developers do not need to worry about these flows implementations anymore, and can concentrate on their business logic.
Motivation
When you develop a web-application in Node.js®, you realize that the easiest way is to use Express. Express supports several plugins (such as session, etc.). However, if some pages in your application are secured and authentication is required - express gives you very little regarding authentication. And, if we are mentioning authentication, we should also point out that a user should be able to self-enroll (register). Similarly, if the user forgets his password, or wants to change it, the application should help him here as well.
I call these flows: “authentication flows”. Every secured web application should support these flows unless it delegates the authentication to a third party (such as oAuth2.0), so we end up in the same code written again and again. If you develop several secured web apps, you may find yourself copy-pasting the code from one app to another. And, if you find a bug in your implementation, you have to fix it in all the applications.
I thought of writing a package that can be reused, so any secured web applications can add this package as a dependency, and bam – all the flows mentioned above are implemented, with a minimal set of configurations. This way, developers can concentrate on developing the core of their app, instead of messing around with flows that are definitely not the core of their business.
I call this module ‘authentication flows module’ or AFM in short.
Requirements (and Assumptions)
First off, I assume that the application uses something that is called “local strategy”, which means the application manages its own usernames and passwords. No oAuth, no cloud solutions like AWS Cognito, etc.
Second, I assume that the hosting web-app is express-based. After the app initializes express (including body-parser and express-session), it passes it to the AFM, so AFM adds endpoints to it. Thus, the application supports all endpoints like /login
, /createAccount
, /forgotPassword
, and so on.
Third, it is reasonable to assume that the application has its own UI – each app wants its pages to maintain the same look and feel, and this includes the login page, create account page, and so on. One application might use ejs, a second application may use simple HTML/CSS, and a third React, and so on. Thus, the AFM which implements the backend part must be totally decoupled from the UI.
Fourth, the AFM sends emails to the user: verification emails, unlock account and restoration (forgot password) emails. Each application manages its own way of sending emails. Perhaps the hosting application sends emails in its logic, so it should reuse the same email server. In other cases, the hosting application might prefer to work with a specific provider like Google, mailgun or just an SMTP server like SMTP2GO. The AFM should be flexible enough to support all these cases.
In these emails, a link is sent to the user. This link should expire after a pre-configured time and should be valid only once.
Fifth, each application uses a different repository (or repositories). One approach could be that AFM will choose a repo implementation – mongoDB for instance - and store the data there. But the downside is that it forces all hosting applications to use mongoDB. What happens if some app does not want to or cannot use mongoDB? Therefore, the AFM should be flexible for different repositories implementations.
Sixth, AFM will be as extensible as it can. It will expose endpoints to the hosting application so some parts can be extended. For example, if the hosting application allows emails (username) only from a specific domain, AFM will expose an API that can be implemented and executed during the create account flow.
Security. Security. Security.
Design
Repository
The AFM stores user information (encoded credentials, etc.) with your data, in your storage solution. It accesses it using the repository layer with the API (interface). The hosting application decides which repo it uses, and it needs to use the implementation for the required repo. For example, if the hosting application uses SQL, it can use the existing implementation for SQL. If it uses a repo which does not currently have an implementation (such as CouchDB or Cassandra), it must implement the interface for that repo.
A default in-memory implementation is supplied (mainly for testing).
The easiest and most compact solution is to develop implementations for all reposin the AFM, but this solution would end up with a super heavy module, which is dependent on all clients of the repos. For example, to support SQL, elasticsearch and mongoDB, the AFM must include, in the package.json, the mongoDB client, SQL client, and so on. Another drawback is if we find a bug in one of the implementations, we have to issue a new version for the whole AFM with that fix.
Our approach is to issue a separate module for each implementation. Thus, there would be a module for in-mem implementation, another for mongoDB, and so on. This way, the AFM itself is more robust and totally decoupled from the repo-implementation, it is more lightweight, and each implementation is independent of the others. Therefore, the hosting app will have to be dependent on AFM plus the repo-implementation that it uses.
DB implementation mode
Interface AuthenticationAccountRepository
import { AuthenticationUser } from "../..";
export interface AuthenticationAccountRepository
{
loadUserByUsername(email: string): Promise<AuthenticationUser>;
createUser(authenticationUser: AuthenticationUser): void;
deleteUser(email: string): void;
userExists(username: string): Promise<boolean>;
setEnabled(email: string);
setDisabled(email: string);
isEnabled(email: string): Promise<boolean>;
decrementAttemptsLeft(email: string);
setAttemptsLeft(email: string, numAttemptsAllowed: number);
setPassword(email: string, newPassword: string);
getEncodedPassword(username: string): Promise<string>;
getPasswordLastChangeDate(email: string): Promise<Date>;
setAuthority(username: string, authority: string);
addLink(username: string, link: string);
removeLink(username: string): Promise<boolean>;
getLink(username: string): Promise<{ link: string, date: Date }>;
getUsernameByLink(link: string): Promise<string>;
}
Email
After user registration, the AFM sends a verification email to the user. In other use-cases, if the user forgets his password, the AFM sends a link to the registered email, to verify it is him. In another use-case, the account is locked when the allowed number of login attempts is exceeded.
There are many ways to send emails, and each application can choose its preferred way. nodemailer is a convenient way to send emails, but you need to configure a provider (Google, Microsoft, etc.). SMTP2GO is an example of a provider.
Another email option is mailgun, which allows you to send email using their API (and not only SMTP).
The AFM allows the hosting application to decide how it sends emails. By default, AFM uses nodemailer over SMTP2GO, but the hosting application can implement interface MailSender
and have its own implementation for sending emails. It must be configured with the credentials of the hosting app (for example, SMTP2GO requires username/password, mailgun requires an APIKEY
, and so on).
Links
In the emails mentioned earlier, there are links that should be clicked. By clicking on an activation link, for example, the user confirms the registered email is valid. When the server responds to the link, it needs to know which user clicked it.
One approach is to encrypt the username with the expiration date of the link, encode it and send it to user. But since the AFM ensures the link is used only once, it is stored in the DB. When the link is created, it is stored in the DB, and when it is used, it is removed. If the link is clicked again, the AFM will not find it in the repo and will throw an error.
Another approach is to generate a UUID (AKA “token”) and send it to the user. This token is stored in the repo in the same record as the username, so there is no need to encrypt and encode (and so no need for private/public keys). When the user clicks the link, AFM searches for it in the repo, and finds who the user is.
One way or another, AFM needs to store something in the repo. AFM uses the second approach and stores the generated token along with the time the link was created, to track expiration. This is stored in the users table.
Extensibility and Customization
AFM is extensible using interceptor classes that can be extended. These methods are invoked by AFM at critical points of the flow. For example, class CreateAccountInterceptor
can be extended and its methods can be overridden. Thus, method postCreateAccount
can be implemented so it would be invoked by AFM after an account is created.
Implementation
Create Account
The user completes the account creation form and clicks “Submit”. The server (AFM) verifies several things. For example, it validates that the email is valid, that the re-typed password matches the password, and that the password meets the password constraints (length, etc.). The hosting application can add more validations by extending the CreateAccountInterceptor
` class. Note, that some validations can (and should) be checked by the UI, but the server should be protective in cases where the UI that hosts AFM is sloppy.
The password is hashed (sha-256) and encoded (base64). This is to ensure that the password that is stored in the DB cannot be revealed.
Next, we check (in the DB ) whether the user (with the same email) already exists and is enabled. In this case, an error is thrown. Otherwise, a new user is created and stored in the DB and an email is sent to the user with a unique string. That string is stored in the DB in the same row/document/record as the user.
The user receives the email to his inbox, and by clicking the link, he confirms that he created the account. If someone tries to create an account with someone else’s email, the link will not be clicked so the account will not be activated.
When the user clicks the link, AFM removes this link from the DB (so it cannot be used again) and activates the account (by enabling it).
Create account sequence chart
async createAccount(email: string, password: string, retypedPassword: string,
firstName: string, lastName: string, serverPath: string) {
AuthenticationFlowsProcessor.validateEmail(email);
this.validatePassword(password);
AuthenticationFlowsProcessor.validateRetypedPassword(password, retypedPassword);
const encodedPassword: string = shaString(password);
this.createAccountEndpoint.additionalValidations(email, password);
email = email.toLowerCase();
debug('createAccount() for user ' + email);
debug('encoded password: ' + encodedPassword);
let authUser: AuthenticationUser = null;
try
{
authUser = await this._authenticationAccountRepository.loadUserByUsername( email );
}
catch(unfe)
{
}
debug(`oauthUser: ${authUser}`);
if(authUser)
{
if( !authUser.isEnabled())
{
await this._authenticationAccountRepository.deleteUser( email );
}
else
{
debug( "cannot create account - user " + email + " already exist." );
throw new AuthenticationFlowsError( USER_ALREADY_EXIST );
}
}
const authorities: string[] = this.setAuthorities();
authUser = new AuthenticationUserImpl(
email, encodedPassword,
false,
this._authenticationPolicyRepository.
getDefaultAuthenticationPolicy().getMaxPasswordEntryAttempts(),
null,
firstName,
lastName,
authorities);
debug(`authUser: ${authUser}`);
await this._authenticationAccountRepository.createUser(authUser);
await this.createAccountEndpoint.postCreateAccount( email );
const token: string = randomString();
const activationUrl: string = serverPath + ACTIVATE_ACCOUNT_ENDPOINT +
"/" + token;
await this._authenticationAccountRepository.addLink( email, token );
debug("sending registration email to " + email + "; activationUrl: " + activationUrl);
await this._mailSender.sendEmail(email,
AUTHENTICATION_MAIL_SUBJECT,
activationUrl );
}
Forgot Password
The user enters their email in the forgot password form, and clicks Submit. The server (AFM) verifies the account exists and is not locked. If it is locked, AFM throws an error. Otherwise, an email is sent to the user with a token. That token is stored in the DB in the same row/document/record of the user.
The user receives the email to their inbox, and by clicking the link confirms that they issued the “forgot password” flow (to avoid malicious party from resetting the password of another account).
When the user clicks the link (that contains the token), the AFM checks the token (existence and expiration). If good, it redirects the user to “set new password” page. Note, we do not remove this token from the DB yet.
The user completes the “set new password” form, and clicks Submit. As in “create account” flow, the server (AFM) validates that the re-typed password matches the password, verifies that the password meets the password constraints (length
, etc.), and then stores the new password (hashed and encoded) in the DB, and removes the token.
Forgot password sequence chart
async forgotPassword(email: string, serverPath: string) {
debug('forgotPassword() for user ' + email);
AuthenticationFlowsProcessor.validateEmail(email);
if( ! await this._authenticationAccountRepository.isEnabled(email) )
{
return;
}
await this.sendPasswordRestoreMail(email, serverPath);
}
Locking Account
An account locked when the user has exceeded the allowed number of login attempts. When that happens, the “enabled
” flag is set to false
, and a re-activation email is sent to the user. The flow is very similar to the account creation (see diagram).
Change Password
In change password flow, the user is already signed in, so AFM does not have to confirm the user’s identity by sending an email; nevertheless, security-wise, AFM sends an email to the user in this flow as well, to protect in the case where the user left their hosting application working and unguarded, and a malicious party tries to change the password. Therefore, the email is sent for confirmation but for notification as well.
Link
In previous versions (before 0.0.82) of AFM (and also in Java’s authentication-flows), it used cryptography in order to encrypt the user name with the expiration time of the link, so when the link is clicked, AFM decrypts it in the server and gets the username and expiration validation. Now, this is no longer needed, and AFM can send just a unique string as a token in the link, which is stored in the DB in relation to the username. When the link is clicked, AFM searches for the token in the DB and finds the username.
How to Get It?
How to Use It?
Sample Application
There is a sample application that uses `authentication-flows-js` so it is a great place to start. Below there are
the required configurations needed.
Repository Adapters
According to the design, the hosting application chooses which repository it works with, and passes the appropriate adapters:
const app = express();
var authFlows = require('authentication-flows-js');
const authFlowsES = require('authentication-flows-js-elasticsearch');
const esRepo = new authFlowsES.AuthenticationAccountElasticsearchRepository();
authFlows.config({
user_app: app,
authenticationAccountRepository: repo,
});
Currently, the following repositories are supported:
Express Server Object
This module *reuses* that client-app' express server and adds several endpoints to it (e.g., `/createAccount`).
Thus, the client-app should pass authentication-flows-js its server object (example above).
Password Policy
authentication-flows-js comes with a default set of configuration for the password policy (in /config/authentication-policy-repository-config.json). The hosting application can replace\edit the JSON file, and use its own preferred values.
The password policy contains the following properties (with the following default values):
{
passwordMinLength: 6,
passwordMaxLength: 10,
passwordMinUpCaseChars: 1,
passwordMinLoCaseChars: 1,
passwordMinNumbericDigits: 1,
passwordMinSpecialSymbols: 1,
passwordBlackList: ["password", "123456"],
maxPasswordEntryAttempts: 5,
passwordLifeInDays: 60
}
An example for a client-app can be found in the sample application, mentioned above.
Security Considerations
- AFM makes sure that the passwords match and meets the minimum requirements.
- To store the credentials (of
SMTP2GO
, for example) away from the code, the hosting application should use dotenv, which reads the environment variables from a local .env file. That way, when application is deployed to production, it can use different production keys that aren’t visible in code, and also not stored in SCM like GitHub. - In “forgot password” flow, even if AFM does not find an email address, it returns 'ok' as status. We don’t want untoward bots figuring out what emails are real vs. not real in our database.
- The more random bytes used in a token, the less likely it can be hacked. AFM is using 64 random bytes in the token generator.
- AFM expires the token in 1 hour. This limits the window of time the reset token works.
- AFM is only looking up reset tokens that have not expired and have not been used.
- After password set, AFM checks the reset token again to make sure it has not been used and has not expired. Need to check it again because the token is being sent by a user via the form.
- Before resetting the password, AFM marks the token as used. That way, if something unforeseen happens (server crash, for example), the password won’t be reset while the token is still valid.
Automated Tests
Needless to say, tests are a critical part of any software development. During AFM development, it was important to ensure that working flows were not damaged as code is reused in several flows. There is a separate project for automated tests, based on Cucumber.
Explaining Cucumber is out of scope here, but it is worth mentioning that all critical flows are tested – automatically. For example, account creation. There is a test that creates and account, activates it with the link, and then checks that the login works.
Another test, for example, is account lock. The test creates an account, activates it, and then fails to login 5 times, until the account is locked. Then, the test re-activates the account using the link, and verifies that login works.
All tests use the web API of the AFM. Thus, with each code change during development, all flows could be tested – in seconds – to avoid regressions.
Cucumber report
Many thanks to a dear friend, David Goldhar for helping me with the review of this article.
History
- 26th April, 2021: Initial version
- 27th April, 2021: Added code snippets and link to project in GitHub