loopback/lib/models/user.js

505 lines
13 KiB
JavaScript
Raw Normal View History

2013-07-01 23:50:03 +00:00
/**
* Module Dependencies.
*/
2013-07-16 17:49:25 +00:00
var Model = require('../loopback').Model
, loopback = require('../loopback')
2013-07-03 05:37:31 +00:00
, path = require('path')
2013-07-15 21:07:17 +00:00
, SALT_WORK_FACTOR = 10
2013-07-03 05:37:31 +00:00
, crypto = require('crypto')
2013-09-12 19:09:43 +00:00
, bcrypt = require('bcryptjs')
2013-07-02 23:51:38 +00:00
, passport = require('passport')
, LocalStrategy = require('passport-local').Strategy
, BaseAccessToken = require('./access-token')
, DEFAULT_TTL = 1209600 // 2 weeks in seconds
2013-11-20 18:59:29 +00:00
, DEFAULT_RESET_PW_TTL = 15 * 60 // 15 mins in seconds
2013-12-11 03:43:59 +00:00
, DEFAULT_MAX_TTL = 31556926 // 1 year in seconds
, Role = require('./role').Role
2014-01-10 18:07:39 +00:00
, ACL = require('./acl').ACL
, assert = require('assert');
2013-07-01 23:50:03 +00:00
2014-01-27 22:31:38 +00:00
var debug = require('debug')('loopback:user');
2013-07-02 00:01:26 +00:00
/**
* Default User properties.
*/
var properties = {
realm: {type: String},
username: {type: String},
password: {type: String, required: true},
email: {type: String, required: true},
2013-07-02 00:01:26 +00:00
emailVerified: Boolean,
2013-07-03 05:37:31 +00:00
verificationToken: String,
2013-07-17 21:08:14 +00:00
2013-07-02 00:01:26 +00:00
credentials: [
'UserCredential' // User credentials, private or public, such as private/public keys, Kerberos tickets, oAuth tokens, facebook, google, github ids
],
challenges: [
'Challenge' // Security questions/answers
],
// https://en.wikipedia.org/wiki/Multi-factor_authentication
/*
factors: [
'AuthenticationFactor'
],
*/
status: String,
created: Date,
lastUpdated: Date
};
2013-12-11 03:43:59 +00:00
var options = {
acls: [
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: ACL.DENY
},
2013-12-11 03:43:59 +00:00
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: ACL.ALLOW,
property: 'create'
},
{
principalType: ACL.ROLE,
principalId: Role.OWNER,
permission: ACL.ALLOW,
property: 'removeById'
2013-12-18 04:42:28 +00:00
},
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: ACL.ALLOW,
property: "login"
},
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: ACL.ALLOW,
property: "logout"
},
{
principalType: ACL.ROLE,
principalId: Role.OWNER,
permission: ACL.ALLOW,
property: "findById"
},
{
principalType: ACL.ROLE,
principalId: Role.OWNER,
permission: ACL.ALLOW,
property: "updateAttributes"
2013-12-11 03:43:59 +00:00
}
],
relations: {
accessTokens: {
type: 'hasMany',
model: 'AccessToken',
foreignKey: 'userId'
}
}
2013-12-11 03:43:59 +00:00
};
2013-07-01 23:50:03 +00:00
/**
2013-07-16 17:49:25 +00:00
* Extends from the built in `loopback.Model` type.
*
* Default `User` ACLs.
*
* - DENY EVERYONE `*`
* - ALLOW EVERYONE `create`
* - ALLOW OWNER `removeById`
* - ALLOW EVERYONE `login`
* - ALLOW EVERYONE `logout`
* - ALLOW EVERYONE `findById`
* - ALLOW OWNER `updateAttributes`
*
* @class
* @inherits {Model}
2013-07-01 23:50:03 +00:00
*/
2013-12-11 03:43:59 +00:00
var User = module.exports = Model.extend('User', properties, options);
2013-07-01 23:50:03 +00:00
2013-07-02 23:51:38 +00:00
/**
* Login a user by with the given `credentials`.
*
* ```js
2013-11-13 19:49:08 +00:00
* User.login({username: 'foo', password: 'bar'}, function (err, token) {
* console.log(token.id);
2013-07-02 23:51:38 +00:00
* });
* ```
2013-07-02 23:51:38 +00:00
*
* @param {Object} credentials
* @callback {Function} callback
* @param {Error} err
* @param {AccessToken} token
2013-07-02 23:51:38 +00:00
*/
User.login = function (credentials, include, fn) {
if (typeof include === 'function') {
fn = include;
include = undefined;
}
include = (include || '').toLowerCase();
2013-07-16 01:22:33 +00:00
var query = {};
if(credentials.email) {
query.email = credentials.email;
} else if(credentials.username) {
query.username = credentials.username;
} else {
var err = new Error('username or email is required');
err.statusCode = 400;
return fn(err);
2013-07-16 01:22:33 +00:00
}
2013-07-28 21:33:13 +00:00
this.findOne({where: query}, function(err, user) {
2013-07-02 23:51:38 +00:00
var defaultError = new Error('login failed');
defaultError.statusCode = 401;
2013-07-02 23:51:38 +00:00
if(err) {
debug('An error is reported from User.findOne: %j', err);
2013-07-02 23:51:38 +00:00
fn(defaultError);
} else if(user) {
user.hasPassword(credentials.password, function(err, isMatch) {
if(err) {
debug('An error is reported from User.hasPassword: %j', err);
2013-07-02 23:51:38 +00:00
fn(defaultError);
} else if(isMatch) {
user.accessTokens.create({
ttl: Math.min(credentials.ttl || User.settings.ttl, User.settings.maxTTL)
}, function(err, token) {
if (err) return fn(err);
if (include === 'user') {
// NOTE(bajtos) We can't set token.user here:
// 1. token.user already exists, it's a function injected by
// "AccessToken belongsTo User" relation
// 2. ModelBaseClass.toJSON() ignores own properties, thus
// the value won't be included in the HTTP response
// See also loopback#161 and loopback#162
token.__data.user = user;
}
fn(err, token);
});
2013-07-02 23:51:38 +00:00
} else {
debug('The password is invalid for user %s', query.email || query.username);
2013-07-02 23:51:38 +00:00
fn(defaultError);
}
});
} else {
debug('No matching record is found for user %s', query.email || query.username);
2013-07-02 23:51:38 +00:00
fn(defaultError);
}
});
};
2013-07-02 23:51:38 +00:00
2013-07-03 05:37:31 +00:00
/**
2013-11-13 19:49:08 +00:00
* Logout a user with the given accessToken id.
2013-07-03 05:37:31 +00:00
*
* ```js
2013-07-03 05:37:31 +00:00
* User.logout('asd0a9f8dsj9s0s3223mk', function (err) {
* console.log(err || 'Logged out');
* });
* ```
2013-07-03 05:37:31 +00:00
*
2013-11-13 19:49:08 +00:00
* @param {String} accessTokenID
* @callback {Function} callback
* @param {Error} err
2013-07-03 05:37:31 +00:00
*/
User.logout = function (tokenId, fn) {
this.relations.accessTokens.modelTo.findById(tokenId, function (err, accessToken) {
2013-07-03 05:37:31 +00:00
if(err) {
fn(err);
2013-11-13 19:49:08 +00:00
} else if(accessToken) {
accessToken.destroy(fn);
2013-07-03 05:37:31 +00:00
} else {
2013-11-13 19:49:08 +00:00
fn(new Error('could not find accessToken'));
2013-07-03 05:37:31 +00:00
}
});
}
2013-07-02 23:51:38 +00:00
/**
* Compare the given `password` with the users hashed password.
*
* @param {String} password The plain text password
* @returns {Boolean}
*/
User.prototype.hasPassword = function (plain, fn) {
2013-07-15 21:07:17 +00:00
if(this.password && plain) {
bcrypt.compare(plain, this.password, function(err, isMatch) {
2013-09-12 19:09:43 +00:00
if(err) return fn(err);
fn(null, isMatch);
});
2013-07-15 21:07:17 +00:00
} else {
fn(null, false);
}
2013-07-02 23:51:38 +00:00
}
/**
* Verify a user's identity by sending them a confirmation email.
2013-07-03 05:37:31 +00:00
*
* ```js
2013-07-03 05:37:31 +00:00
* var options = {
* type: 'email',
* to: user.email,
* template: 'verify.ejs',
* redirect: '/'
* };
*
* user.verify(options, next);
* ```
2013-07-03 05:37:31 +00:00
*
* @param {Object} options
2013-07-02 23:51:38 +00:00
*/
2013-07-03 05:37:31 +00:00
User.prototype.verify = function (options, fn) {
var user = this;
assert(typeof options === 'object', 'options required when calling user.verify()');
assert(options.type, 'You must supply a verification type (options.type)');
assert(options.type === 'email', 'Unsupported verification type');
assert(options.to || this.email, 'Must include options.to when calling user.verify() or the user must have an email property');
assert(options.from, 'Must include options.from when calling user.verify() or the user must have an email property');
2013-07-02 23:51:38 +00:00
2013-07-03 05:37:31 +00:00
options.redirect = options.redirect || '/';
options.template = path.resolve(options.template || path.join(__dirname, '..', '..', 'templates', 'verify.ejs'));
options.user = this;
options.protocol = options.protocol || 'http';
options.host = options.host || 'localhost';
options.verifyHref = options.verifyHref ||
options.protocol
+ '://'
+ options.host
+ User.http.path
2013-07-03 05:37:31 +00:00
+ User.confirm.http.path;
2013-07-02 23:51:38 +00:00
2013-07-03 05:37:31 +00:00
// Email model
2014-01-23 22:40:27 +00:00
var Email = options.mailer || this.constructor.email || loopback.getModelByType(loopback.Email);
2013-07-03 05:37:31 +00:00
crypto.randomBytes(64, function(err, buf) {
if(err) {
fn(err);
} else {
user.verificationToken = buf.toString('base64');
user.save(function (err) {
if(err) {
fn(err);
} else {
sendEmail(user);
}
});
}
});
// TODO - support more verification types
function sendEmail(user) {
options.verifyHref += '?token=' + user.verificationToken;
options.text = options.text || 'Please verify your email by opening this link in a web browser:\n\t{href}';
options.text = options.text.replace('{href}', options.verifyHref);
2013-07-16 17:49:25 +00:00
var template = loopback.template(options.template);
2013-07-03 05:37:31 +00:00
Email.send({
to: options.to || user.email,
subject: options.subject || 'Thanks for Registering',
text: options.text,
html: template(options)
}, function (err, email) {
if(err) {
fn(err);
} else {
fn(null, {email: email, token: user.verificationToken, uid: user.id});
}
});
}
2013-07-02 23:51:38 +00:00
}
/**
* Confirm the user's identity.
*
* @param {Any} userId
* @param {String} token The validation token
* @param {String} redirect URL to redirect the user to once confirmed
* @callback {Function} callback
* @param {Error} err
*/
2013-07-03 05:37:31 +00:00
User.confirm = function (uid, token, redirect, fn) {
this.findById(uid, function (err, user) {
if(err) {
fn(err);
} else {
if(user.verificationToken === token) {
user.verificationToken = undefined;
user.emailVerified = true;
user.save(function (err) {
if(err) {
fn(err)
} else {
fn();
}
});
} else {
fn(new Error('invalid token'));
}
}
});
}
2013-11-20 18:59:29 +00:00
/**
* Create a short lived acess token for temporary login. Allows users
* to change passwords if forgotten.
*
* @options {Object} options
* @prop {String} email The user's email address
* @callback {Function} callback
* @param {Error} err
*/
2013-11-20 18:59:29 +00:00
User.resetPassword = function(options, cb) {
var UserModel = this;
var ttl = UserModel.settings.resetPasswordTokenTTL || DEFAULT_RESET_PW_TTL;
options = options || {};
if(typeof options.email === 'string') {
UserModel.findOne({email: options.email}, function(err, user) {
if(err) {
cb(err);
} else if(user) {
// create a short lived access token for temp login to change password
// TODO(ritch) - eventually this should only allow password change
user.accessTokens.create({ttl: ttl}, function(err, accessToken) {
if(err) {
cb(err);
} else {
cb();
UserModel.emit('resetPasswordRequest', {
email: options.email,
accessToken: accessToken
});
}
})
} else {
cb();
}
});
} else {
var err = new Error('email is required');
err.statusCode = 400;
cb(err);
}
}
/*!
* Setup an extended user model.
2013-07-03 05:37:31 +00:00
*/
User.setup = function () {
// We need to call the base class's setup method
Model.setup.call(this);
2013-07-03 05:37:31 +00:00
var UserModel = this;
// max ttl
this.settings.maxTTL = this.settings.maxTTL || DEFAULT_MAX_TTL;
this.settings.ttl = DEFAULT_TTL;
2013-07-16 01:22:33 +00:00
UserModel.setter.password = function (plain) {
var salt = bcrypt.genSaltSync(this.constructor.settings.saltWorkFactor || SALT_WORK_FACTOR);
2013-08-30 23:52:27 +00:00
this.$password = bcrypt.hashSync(plain, salt);
2013-07-16 01:22:33 +00:00
}
2013-07-16 17:49:25 +00:00
loopback.remoteMethod(
2013-07-02 23:51:38 +00:00
UserModel.login,
{
accepts: [
{arg: 'credentials', type: 'object', required: true, http: {source: 'body'}},
{arg: 'include', type: 'string', http: {source: 'query' }, description:
'Related objects to include in the response. ' +
'See the description of return value for more details.'}
2013-07-02 23:51:38 +00:00
],
returns: {
arg: 'accessToken', type: 'object', root: true, description:
'The response body contains properties of the AccessToken created on login.\n' +
'Depending on the value of `include` parameter, the body may contain ' +
'additional properties:\n\n' +
' - `user` - `{User}` - Data of the currently logged in user. (`include=user`)\n\n'
},
2013-07-02 23:51:38 +00:00
http: {verb: 'post'}
}
);
2013-07-16 17:49:25 +00:00
loopback.remoteMethod(
2013-07-03 05:37:31 +00:00
UserModel.logout,
{
accepts: [
{arg: 'access_token', type: 'string', required: true, http: function(ctx) {
var req = ctx && ctx.req;
var accessToken = req && req.accessToken;
var tokenID = accessToken && accessToken.id;
return tokenID;
}, description:
'Do not supply this argument, it is automatically extracted ' +
'from request headers.'
}
2013-07-03 05:37:31 +00:00
],
http: {verb: 'all'}
}
);
2013-07-16 17:49:25 +00:00
loopback.remoteMethod(
2013-07-03 05:37:31 +00:00
UserModel.confirm,
{
accepts: [
{arg: 'uid', type: 'string', required: true},
{arg: 'token', type: 'string', required: true},
{arg: 'redirect', type: 'string', required: true}
],
http: {verb: 'get', path: '/confirm'}
}
);
2013-11-20 18:59:29 +00:00
loopback.remoteMethod(
UserModel.resetPassword,
{
accepts: [
{arg: 'options', type: 'object', required: true, http: {source: 'body'}}
],
http: {verb: 'post', path: '/reset'}
}
);
2013-07-03 05:37:31 +00:00
UserModel.on('attached', function () {
UserModel.afterRemote('confirm', function (ctx, inst, next) {
if(ctx.req) {
ctx.res.redirect(ctx.req.param('redirect'));
} else {
fn(new Error('transport unsupported'));
}
});
});
// default models
UserModel.email = require('./email');
2013-11-13 19:49:08 +00:00
UserModel.accessToken = require('./access-token');
UserModel.validatesUniquenessOf('email', {message: 'Email already exists'});
var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
UserModel.validatesFormatOf('email', {with: re, message: 'Must provide a valid email'});
2013-07-02 23:51:38 +00:00
return UserModel;
}
2013-07-03 05:37:31 +00:00
/*!
* Setup the base user.
*/
2013-07-17 21:08:14 +00:00
User.setup();