upgrading feathers-authentication-management (a.k.a. f-a-m) to authentication-local-management (a.k.a. a-l-m)
f-a-m encrypted the password internally. The most common issue occurring in that repo was the app rehashing the password in the before hooks. The app had to run hashPassword in the hooks only if the service had not been by the repo.
Related to this, some devs asked about the possibility to use alternate hash functions rather than the one Feathers uses in hashPassword.
a-l-m addresses both concerns.
a-l-m does not encrypt the password internally. The hooks on the user service must do so for all service calls, including those made by a-l-m.
You can use an alternate hash function, if you want to, by using another hashing hook.
You may want to do this if hashPassword function is too expensive in computation. You can display the elapsed times for it, and some alternatives, by running misc/hash-timing-tests.js. Results vary from run to run as hashPassword randomly varies the number of hash cycles to impede timing attacks.
You will also have to pass a-l-m an option which compares a plain string to its encrypted value. See bcryptjs##compare for information on such a function's signature. The repo uses the callback version of that function. The default option is:
const bcrypt = require('bcryptjs');
app.configure(authLocalMgnt({
bcryptCompare: bcrypt.compare,
}));
However additional work is still needed for the authentication verifier.
daffl Nov. 08, 2018 [2:03 PM]
It’s an option now (https://github.com/feathersjs/feathers/blob/master/packages/authentication-local/lib/hooks/hash-password.js)
`hashPassword({ bcrypt: require('bcrypt') })`
Hasn’t been added to the verifier yet unfortunately so you have to extend it
and implement your own `_comparePassword` (https://docs.feathersjs.com/api/authentication/local.html#verifier)
By default, unauthenticated users may continue to make these calls
- resendVerifySignup
- verifySignupLong
- verifySignupShort
- sendResetPwd
- resetPwdLong
- resetPwdShort
You can override this with options.actionsNoAuth whose default is
actionsNoAuth: [
'resendVerifySignup', 'verifySignupLong', 'verifySignupShort',
'sendResetPwd', 'resetPwdLong', 'resetPwdShort',
],
Client calls for passwordChange and changeProtectedFields may now only affect their own account. This can be controlled by options.ownAcctOnly whose default is true.
user.isVerified
is no longer checked for calls made by the server.
The client convenient methods in feathers-authentication-management/src/client.js have been removed. They were one line wrappers around service calls to the f-a-m service. They created a conceptual burden for little in return. People did not look at their code and ended up asking things like how to use them with Redux, something they would have known how to do with the service call itself.
Each call to a convenience method can be replaced with a one line service call.
async/await is used throughout the repo. The repo interfaces may be called either with await, or by using Promises. So the interfaces themselves are backwards compatible.
The user-entity service name and the name of the password field in its records now defaults config/default.json##authentication##local##entity and ##passwordField. These can be overridden with the a-l-m options service and passwordField.
The signature of options.sanitizeUserForClient is now (user, passwordField) instead of (user).
The second most common issue raised with f-a-m was how to use it with Sequelize/Knex. f-a-m expected the user-entity model to be in a JS-friendly format, and the dev was expected to use hooks to reformat that to the Sequelize/Knex model.
The sequelizeConvertAlm hook has been introduced as a convenience. It converts the isVerified, verifiedExpires, verifyChanges, resetExpires fields created by this repo. Its used on the user-entity as follows:
const { sequelizeConvertAlm } = require('authentication-local-management').hooks;
module.exports = {
before: {
all: sequelizeConvertAlm(),
},
after: {
all: sequelizeConvertAlm(),
},
};
By default it converts
Field name Internal Sequelize & Knex
----------- -------- ----------------
isInvitation Boolean INTEGER
isVerified Boolean INTEGER
verifyExpires Date.now() DATE (*)
verifyChanges Object STRING, JSON.stringify
resetExpires Date.now() DATE (*)
mfaExpires Date.now() DATE (*)
passwordHistory Array STRING, JSON.stringify
(*) The hook passes the 2 datetimes to the adapter as Date.now() when used as a before hook. The adapter converts them to the DB format. The hook itself converts the datetimes back to Date.now() when run as an after hook.
There are options to
- Customize the datetime conversion,
- Customize the convertNonSqlType conversion,
- Skip converting any of these fields.
The test/sequelize.test.js module uses the feathers-sequelize adapter with an sqlite3 table created with
authentication-local-management$ touch ./test-data/users.sqlite
authentication-local-management$ sqlite3 ./test-data/users.sqlite
SQLite version 3.19.3 2017-06-08 14:26:16
Enter ".help" for usage hints.
sqlite> .schema
sqlite> CREATE TABLE 'Users' ('id' INTEGER PRIMARY KEY AUTOINCREMENT,
'email' VARCHAR( 60),
'password' VARCHAR( 60),
'phone' VARCHAR( 30),
'dialablePhone' VARCHAR( 15),
'preferredComm' VARCHAR( 5),
'isInvitation' INTEGER,
'isVerified' INTEGER,
'verifyExpires' DATETIME,
'verifyToken' VARCHAR( 60),
'verifyShortToken' VARCHAR( 8),
'verifyChanges' VARCHAR(255),
'resetExpires' INTEGER,
'resetToken' VARCHAR( 60),
'resetShortToken' VARCHAR( 8),
'mfaExpires' INTEGER,
'mfaShortToken' VARCHAR( 8),
'passwordHistory' VARCHAR(512),
'createdAt' DATETIME,
'updatedAt' DATETIME
);
sqlite> .quit
Module users.sequelize.js much be customized to reflect the changes sequelizeConvertAlm makes:
sequelizeClient.define('users',
{
email: {
type: DataTypes.STRING,
allowNull: false
},
password: {
type: DataTypes.STRING,
allowNull: false
},
phone: {
type: DataTypes.STRING,
allowNull: false
},
dialablePhone: {
type: DataTypes.STRING,
allowNull: false
},
preferredComm: {
type: DataTypes.STRING,
allowNull: false
},
isInvitation: {
type: DataTypes.INTEGER,
allowNull: false
},
isVerified: {
type: DataTypes.INTEGER,
allowNull: false
},
verifyExpires: {
type: DataTypes.DATE
},
verifyToken: {
type: DataTypes.STRING,
allowNull: false
},
verifyShortToken: {
type: DataTypes.STRING,
allowNull: false
},
verifyChanges: {
type: DataTypes.STRING
},
resetExpires: {
type: DataTypes.DATE
},
resetToken: {
type: DataTypes.STRING,
allowNull: false
},
resetShortToken: {
type: DataTypes.STRING,
allowNull: false
},
mfaExpires: {
type: DataTypes.DATE
},
mfaShortToken: {
type: DataTypes.STRING,
allowNull: false
},
passwordHistory: {
type: DataTypes.STRING
},
},
{
hooks: {
beforeCount(options) {
options.raw = true;
},
},
},
);
People inevitably find valid reasons for wanting to customize the service calls being made by a repo. A good example is provided in feathersjs-ecosystem/feathers-authentication-management#107 where the calls need to be customized to identify a set of calls for transaction roll back.
You can customize every call in the repo using the new options.customizeCalls. It defaults to
{
checkUnique: {
find: async (usersService, params = {}) =>
await usersService.find(params),
},
changeProtectedFields: {
find: async (usersService, params = {}) =>
await usersService.find(params),
patch: async (usersService, id, data, params = {}) =>
await usersService.patch(id, data, params),
},
passwordChange: {
find: async (usersService, params = {}) =>
await usersService.find(params),
patch: async (usersService, id, data, params = {}) =>
await usersService.patch(id, data, params),
},
resendVerifySignup: {
find: async (usersService, params = {}) =>
await usersService.find(params),
patch: async (usersService, id, data, params = {}) =>
await usersService.patch(id, data, params),
},
resetPassword: {
resetTokenGet: async (usersService, id, params) =>
await usersService.get(id, params),
resetShortTokenFind: async (usersService, params = {}) =>
await usersService.find(params),
badTokenpatch: async (usersService, id, data, params = {}) =>
await usersService.patch(id, data, params),
patch: async (usersService, id, data, params = {}) =>
await usersService.patch(id, data, params),
},
sendResetPwd: {
find: async (usersService, params = {}) =>
await usersService.find(params),
patch: async (usersService, id, data, params = {}) =>
await usersService.patch(id, data, params),
},
verifySignup: {
find: async (usersService, params = {}) =>
await usersService.find(params),
patch: async (usersService, id, data, params = {}) =>
await usersService.patch(id, data, params),
},
};
You can check the src/ modules for where these are called.
You can provide an options.customizeCalls object when initializing the a-l-m. Your functions will be merged with the defaults, so you only need specify the ones which changed.
When a-l-m throws for any reason, option.catchErr is called. The default is
catchErr: (err, options, data) => {
return Promise.reject(err); // support both async and Promise interfaces
},
You can override this to return whatever error you want (by throwing) or returning whatever response you want to the client (by return {...}).
This handles issues such as feathersjs-ecosystem/feathers-authentication-management#85
It now works correctly when context.data is an array.
a-l-m is configured like this
??????????????????? todo
If you are using the cli+ generator, the user entity's JSON-schema could be defined as
// Fields in the model.
properties: {
// !code: schema_properties
/* eslint-disable */
_id: { type: 'ID' },
email: { type: 'string', minLength: 8, maxLength: 40, faker: 'internet.email' },
password: { type: 'string', minLength: 4, maxLength: 30 },
phone: { type: 'string', minLength: 7, maxLength: 30, faker: { 'phone.phoneNumber': '(###) ###-####' } },
dialablePhone: { type: 'string', minLength: 7, maxLength: 15, faker: { 'phone.phoneNumber': '+1##########' } },
preferredComm: { type: 'string', enum: ['email', 'phone'] },
isVerified: { type: 'boolean' },
verifyExpires: { type: 'integer', faker: { exp: 'Date.now() + 5' } },
verifyToken: { type: 'string', minLength: 30, maxLength: 30, faker: { exp: 'null' } },
verifyShortToken: { type: 'string', minLength: 6, maxLength: 6, faker: { exp: 'null' } },
verifyChange: { type: 'array', faker: { exp: '[]' } },
resetExpires: { type: 'integer', faker: { exp: 'Date.now() + 8' } },
resetToken: { type: 'string', minLength: 30, maxLength: 30, faker: { exp: 'null' } },
resetShortToken: { type: 'string', minLength: 6, maxLength: 6, faker: { exp: 'null' } },
/* eslint-enable */
// !end
},
For Mongoose
email: { type: String },
password: { type: String },
phone: { type: String },
dialablePhone: { type: String },
preferredComm: { type: String, enum: ['email', 'phone'] },
isVerified: { type: Boolean },
verifyToken: { type: String },
verifyExpires: { type: Date },
verifyChanges: { type: Object },
resetToken: { type: String },
resetExpires: { type: Date }
For Sequelize (duplicate) The test/sequelize.test.js module uses the feathers-sequelize adapter with an sqlite3 table created with
$ sqlite3 ./testdata/users.sqlite3
SQLite version 3.19.3 2017-06-08 14:26:16
Enter ".help" for usage hints.
sqlite> .schema
sqlite> CREATE TABLE 'Users' ('id' INTEGER PRIMARY KEY AUTOINCREMENT,
'email' VARCHAR( 60),
'password' VARCHAR( 60),
'phone' VARCHAR( 30),
'dialablePhone' VARCHAR( 15),
'preferredComm' VARCHAR( 5),
'isVerified' INTEGER,
'verifyExpires' DATETIME,
'verifyToken' VARCHAR( 60),
'verifyShortToken' VARCHAR( 8),
'verifyChanges' VARCHAR(255),
'resetExpires' INTEGER,
'resetToken' VARCHAR( 60),
'resetShortToken' VARCHAR( 8)
);
sqlite>
a-l-m externalizes some of the required processing into hooks. This allows you to customize things like the hashing function.
The user-entity hooks would typically be configured as shown below. Note the sequelizeConvertAlm hook is used only with user-entities using the Sequelize or Knex adapter.
const { authenticate } = require('@feathersjs/authentication').hooks;
const { hashPassword, protect } = require('@feathersjs/authentication-local').hooks;
const { addVerification, sequelizeConvertAlm, isVerified } =
require('@feathers-plus/authentication-local-management').hooks;
let moduleExports = {
before: {
all: [
// Convert isVerified, verifyExpires, verifyChanges, resetExpires to SQL format.
sequelizeConvertAlm(),
],
find: [
authenticate('jwt'),
// Check user has been verified i.e. isVerified === true.
isVerified(),
],
get: [ authenticate('jwt'), isVerified() ],
create: [
// Hash password, verifyToken, verifyShortToken, resetToken, resetShortToken.
hashPassword(),
authenticate('jwt'), isVerified(), // may or may not want to have this depending on the app
// Add fields required by authentication-local-management:
// isVerified, verifyToken, verifyShortToken, verifyChanges,
// resetExpires, resetToken & resetShortToken.
addVerification(),
],
update: [ hashPassword(), authenticate('jwt'), isVerified() ],
patch: [ hashPassword(), authenticate('jwt'), isVerified() ],
remove: [ authenticate('jwt'), isVerified() ]
},
after: {
all: [
// Convert isVerified, verifyExpires, verifyChanges, resetExpires from SQL format.
sequelizeConvertAlm(),
protect('password') /* Must always be the last hook */
],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: []
},
};
The isVerified would no longer be needed once its merged into feathersjs/authentication-local.
Services available only to authenticated clients would be configured like
const { authenticate } = require('@feathersjs/authentication').hooks;
const { isVerified } = require('@feathers-plus/authentication-local-management').hooks;
module.exports = {
before: {
all: [ authenticate('jwt'), isVerified() ],
// ...
},
};
The isVerified would no longer be needed once its merged into feathersjs/authentication-local.
This is an example of configuring the client, including authenticating the client
and configuring localManagement
.
The localManagement
service needs a timeout greater than 5 seconds as multiple values may be hashed.
async function configClient(host, port, email1, password1,
timeoutSocketio = 20000, timeoutAuthenticationClient = 20000, timeoutAuthLocalMgntClient = 20000
) {
const socket = io(`http://${host}:${port}`, {
transports: ['websocket'],
forceNew: true,
reconnection: false,
extraHeaders: {},
timeout: timeoutSocketio,
});
client = feathersClient();
client.configure(feathersClient.socketio(socket));
client.configure(feathersClient.authentication({
storage: localStorage,
timeout: timeoutAuthenticationClient,
}));
try {
await client.authenticate({
strategy: 'local',
email: email1,
password: password1,
});
console.log(' ... client authenticated');
} catch (err) {
console.log(' ... client could not authenticate.', err);
throw new Error(`Unable to authenticate: ${err.message}`);
}
const authLocalMgntClient = client.service('localManagement');
authLocalMgntClient.timeout = timeoutAuthLocalMgntClient; // 20000 !important
return client;
}
And you can logout with
client.logout();
-
???? do we need a language field for i18n?
-
WhatsApp as alternative to phone
-
Have multiple passwords, e.g. pin number
-
pluginsDefaultOptions for plugins-default.js ?