- The user 'Alice' forgets the password of her local account
- She clicks the 'Forgot my password'-link which takes her to /resetpassword
- A page with a form is displayed where she can enter the username she uses to login with (we currently do not have the functionality to look up a user by email)
- She fills in her username and submits the form
- The backend sends an e-mail to the e-mail address that is stored with the 'Alice' account
- There is a link in the e-mail that takes her to a page where she can enter a new password for the Alice account.
Couple of noteworthy things:
- Submitting the form should not change the current password.
- The generated link is only valid for x hours (24?)
- The link contains a randomly generated string that will ensure it's a valid request.
- Submitting the form doesn't actually take the user anywhere, but performs an AJAX request:
POST /api/auth/local/reset/init
username=alice
- The link takes the user to
/resetpassword/alice/abcdefghijklmnopq
where abcdefghijklmnopq is the randomly generated string. (We'll need to add a rewrite entry in our nginx config.) That page shows the form where Alice can enter a new password, when she submits an AJAX request gets triggered:
POST /api/auth/local/reset/change
username=alice
secret=abcdefghijklmnopq
newPassword=<alice her new password>
- The backend returns 200 if succesfull, 401 if not.
Each user can have multiple login IDs, this allows a user to login with multiple accounts to the same 'OAE account'. A user can for example, link his organization external SSO account, twitter account and Facebook account all to the same OAE account.
Some login ID examples:
- cam:twitter:Alice1987 for logging in via the 'Alice1987' twitter account on the tenant with alias 'cam'
- cam:local:alice for logging in with a local 'alice' account on the tenant with alias 'cam'
There are a couple of column families (CF) in Cassandra (~tables in SQL world) that are of note
This contains the principal(=user or group) information. This CF has columns for:
- the
principalId, ex: u:cam:TTacvL3Hcf; The primary identifier for the user or group. - displayName, the user his or her name
- locale, the user his or her locale
- email, the user his or her email
A simple row where the row key is the principalId identifying the user and a list of columns where each column is a loginId. Most rows will only have 1 loginId as most users will most likely only use one account to log in.
Say for example, that Alice has a local and a twitter loginId, her row might look something like this:
principalId | cam:local:alice | cam:twitter:Alice1987 u:cam:TTacvL3Hcf | 1 | 1
This CF stores data against a login ID. In our usecase we're only interested in the local login strategy which stores the following data:
- loginId - ex: cam:local:alice
- password - A salted sha512 hash of the password
This is the CF will be storing the secret string in that allows Alice to reset her password.
Most of the code in this area is located in the oae-authentication module. (We currently have a ticket to clean up the API and split it up in multiple files, so it's not the prettiest right now)
What will be of interest to you:
This file contains all the authentication-related REST endpoints. We try to keep our rest.js files as clean as possible, so we usually call down to the proper api functions.
Well need to add 2 endpoints: /api/auth/local/reset/init calls AuthenticationAPI.getResetPasswordSecret(req.ctx, req.body.username, function(err) { .. }); /api/auth/local/reset/change calls AuthenticationAPI.resetPassword(req.ctx, req.body.username, req.body.secret, req.body.newPassword, function(err) { .. })
This file contains all the authentication logic and is the place where we'll add the logic for resetting the password.
The getResetPasswordSecret will do the following things:
- Construct the cam:local:alice loginId
var loginId = new LoginId(ctx.tenant().alias, AuthenticationConstants.providers.LOCAL, username);
- Check if that login ID is stored in Cassandra (ie: that it's associated to some account)
_getUserIdFromLoginId(loginId, function(err, userId) { .. })
- Get the User object as we'll need it to send Alice an e-mail.
PrincipalsAPI.getUser(ctx, userId, function(err, user) { .. })
- Generate a random string [1] and store it in the
AuthenticationLoginIdCF in thesecretcolumn. We can set a TTL of 24 hours on thesecretcolumn, so it automatically expires.
var secret = crypto.randomBytes(16).toString('hex');
Cassandra.runQuery('UPDATE AuthenticationLoginId USING CONSISTENCY QUORUM AND TTL 86400 SET secret = ? WHERE loginId = ?', [secret, _flattenLoginId(loginId)], function(err) { .. })
- Send out an e-mail which we can do with the
oae-emailmodule [2].
var emailData = {'secret': secret};
EmailsAPI.sendEmail('oae-authentication', 'reset-password', user, emailData, null, function(err) { .. })
- We're done, pass control back to the REST endpoint which can send back a 200.
callback();
In order for the above to work you'll need to add an email template to the oae-authentication module. Have a look at the oae-content/emailTemplates to see how they work
The resetPassword will do the following things:
- Construct the cam:local:alice loginId
var loginId = new LoginId(ctx.tenant().alias, AuthenticationConstants.providers.LOCAL, username);
- Get the secret from the database
Cassandra.runQuery('SELECT userId, secret FROM AuthenticationLoginId USING CONSISTENCY QUORUM WHERE loginId = ?', [_flattenLoginId(loginId)], function(err, rows) {
if (err) { return callback(err); }
// Get the secret column out of the row
var dbSecret = rows[0].get('secret')
// If the secret column was not found or its value didn't match, something is wrong.
if (!dbSecret || dbSecret.value !== secret) {
log().warn({'loginId': loginId}, 'Potential hacking attempt detected');
return callback({'code': 401, 'msg': 'The secret was not found or incorrect'})
}
- Change the password.
_changePassword(loginId, newPassword, callback);
- Done.
You'll need to add 2 entries to your nginx config rewrite ^/resetpassword$ /ui/resetpassword.html last; rewrite ^/resetpassword/(.*) /ui/resetpassword.html last;