UNPKG

9.6 kBJavaScriptView Raw
1"use strict";
2
3const audit = require('../helper/audit');
4const failureImpl = require('../restriction/failure');
5const generatePassword = require('../password/generate');
6const json = require('body-parser')
7 .json();
8const recaptchaImpl = require('../restriction/recaptcha');
9
10const Auth = require('./Auth');
11const LocalStrategy = require('passport-local');
12
13/**
14 * Email based login.
15 *
16 * Requies ```passport-local``` package.
17 */
18class EmailAuth extends Auth
19{
20
21 /**
22 * @param {object} options see Auth class + additional options for email configuration.
23 * @property {number} [options.tokenExpxiryMinutes=10] number of minutes to restrict token exchange to for passwordless login
24 */
25 constructor(options = {})
26 {
27 super('email', options);
28
29 /**
30 * Instance of email sender class for sending emails.
31 *
32 * If this is not specified, email login is disabled.
33 *
34 * @type {EmailSender}
35 */
36 this.emailSender = options.emailSender;
37
38 /**
39 * Instance of crypt class for encrypting and verifying passwords.
40 *
41 * @type {Crypt}
42 */
43 this.crypt = options.crypt;
44
45 /**
46 * Name of application.
47 * Used for sending email.
48 * @type {string}
49 */
50 this.applicationName = options.applicationName || 'Account';
51
52 /**
53 * Email from address
54 * Used for sending email.
55 * @type {string}
56 */
57 this.fromAddress = options.fromAddress || 'no-thanks@reply-factory.com';
58
59 this.description.usesPassword = true;
60 this.description.tokenExpiryMinutes = options.tokenExpiryMinutes || 10;
61
62 /**
63 * @private
64 */
65 this.tokenExpiryMilliseconds = this.description.tokenExpiryMinutes * 60 * 1000;
66
67 /**
68 * Settings for rate limiting failed requests.
69 *
70 * Note: use this or recaptcha.
71 */
72 this.block = options.block || undefined;
73
74 /**
75 * Settings for rate limiting through recaptcha.
76 *
77 * Note: use this or recaptcha.
78 */
79 this.recaptcha = options.recaptcha || undefined;
80
81
82 if (this.recaptcha)
83 {
84 if (this.recaptcha.publicKey)
85 {
86 this.description.recaptcha = this.recaptcha.publicKey;
87 }
88 else
89 {
90 this.recaptcha = undefined;
91 }
92 }
93
94 /**
95 * If true, it remembers any passwords specified registration.
96 */
97 this.allowPasswordSettingDuringRegistration = options.allowPasswordSettingDuringRegistration || false;
98
99
100 /**
101 * login security tokens
102 * @protected
103 */
104 this.tokens = {};
105 }
106
107 /**
108 * logs users in based on token or based on username(email)/password
109 */
110 async strategyImpl(req, username, password, done)
111 {
112 const tokens = this.tokens;
113
114 // call if unsuccessful
115 function error(msg, detailed = undefined)
116 {
117 req.audit(audit.LOGIN_FAILURE, msg, detailed);
118 if (typeof msg === 'string')
119 {
120 msg = {
121 error: msg
122 };
123 }
124 done(null, msg);
125 }
126
127 // expire aged tokens
128 const now = Date.now();
129
130 for (let tok in tokens)
131 {
132 if (now >= tokens[tok].expires)
133 {
134 delete tokens[tok];
135 }
136 }
137
138 // passwordless login
139 if (tokens[username])
140 {
141 if (tokens[username].password === password)
142 {
143 const profile = await this.createProfileFromEmail(username, tokens[username].extra);
144
145 delete tokens[username];
146
147 this.handleUserLoginByProfile(username, profile, done, req);
148 }
149 else
150 {
151 error('Temporary Login password did not match.', username);
152 }
153 }
154 else // username / password login
155 {
156 let user = this.findUser(username);
157
158 if (user && user.credentials) // if found, log in found account
159 {
160 for (let credential of user.credentials)
161 {
162 if (credential.type === this.method && credential.value === username && user.password)
163 {
164 this.crypt.verify(password, user.password)
165 .then((verified) =>
166 {
167 if (verified)
168 {
169 done(null, user);
170 }
171 else
172 {
173 error('Email and password combination not found.', username);
174 }
175 }, done);
176
177 return;
178 }
179 }
180 }
181 error('Email and password combination not found.', username);
182 }
183 }
184
185 /**
186 * sends temporary login password to email address
187 */
188 async passwordlessImpl(req, res)
189 {
190 const tokens = this.tokens;
191 try
192 {
193 const username = req.body.username;
194
195 if (!req.body.username || typeof req.body.username !== 'string')
196 {
197 return res.error('Username not specified', audit.LOGIN_FAILURE);
198 }
199 const temporaryPassword = generatePassword();
200 const theme = req.params.theme || 'login';
201
202 await this.sendTemporaryPassword(theme, username, temporaryPassword, this.description.tokenExpiryMinutes, req.body.loginLinkPrefix);
203 tokens[username] = {
204 password: temporaryPassword,
205 expires: Date.now() + this.tokenExpiryMilliseconds,
206 extra: req.body,
207 };
208 res.success('A login email has been sent to email address.', audit.LOGIN, username);
209 }
210 catch (e)
211 {
212 console.log(e);
213 res.error('Error sending email. Please verify that your address is correct and is valid.', audit.LOGIN_FAILURE.e);
214 }
215 }
216
217 /**
218 * @override
219 */
220 install(app, prefix, passport)
221 {
222 // Protect methods with restriction.
223 // Verify does not need it as we are doing simple memory lookup in small
224 // sized table. May still be an issue unless we receive enough registrations
225 // to fill up memory.
226 const limit = this.block ? failureImpl(this.block) : recaptchaImpl(this.recaptcha);
227
228 passport.use(new LocalStrategy({
229 passReqToCallback: true,
230 }, this.strategyImpl.bind(this)));
231
232 app.all([`${prefix}/login.json`,
233 `${prefix}/verify.json`
234 ],
235 json,
236 limit,
237 passport.authenticate('local', this.authenticateOptions),
238 this.loggedIn());
239
240 // PASSWORDLESS LOGIN
241
242 if (this.emailSender)
243 {
244 app.all(`${prefix}/:theme.json`, json, limit, this.passwordlessImpl.bind(this));
245 }
246 }
247
248 /**
249 * helper method that creates a profile object from an email address
250 */
251 async createProfileFromEmail(id, extra = {})
252 {
253 let displayName = extra.displayName || id.substr(0, id.indexOf('@'));
254 let user = {
255 id,
256 displayName
257 };
258
259 if (extra.password && this.crypt)
260 {
261 user.password = await this.crypt.hash(extra.password);
262 }
263
264 return user;
265 }
266
267
268 /**
269 * Override of helper method that inserts password into produced user
270 * if allowed by setting.
271 *
272 * @override
273 */
274 createUserFromProfile(profile)
275 {
276 // we allow password setting
277 let user = super.createUserFromProfile(profile);
278
279 if (profile.password && this.allowPasswordSettingDuringRegistration)
280 {
281 user.password = profile.password;
282 }
283
284 return user;
285 }
286
287 /**
288 * Helper method that sends temporary password to an email address for rego/login.
289 *
290 * Should be able to override this to specify custom formats for email.
291 *
292 * @param {string} theme register | recover | passwordless etc. anything other than login or verify
293 * @param {string} to target email address
294 * @param {string} password temporary login token
295 * @param {number} expireMinutes number of minutes after which this login token will expire
296 * @param {string} [loginLinkPrefix] forward url for any links in email
297 */
298 sendTemporaryPassword(theme, to, password, expireMinutes, loginLinkPrefix)
299 {
300 let subject = `${this.applicationName}`;
301
302 switch (theme)
303 {
304 case 'register':
305 subject += ' Registration';
306 break;
307 case 'recover':
308 subject += ' Account Recovery';
309 break;
310 default:
311 subject += ' Login';
312 break;
313 }
314
315 let message = '';
316
317 message += `<p>You have received this email because someone requested access to the ${this.applicationName} using this email address.</p>\n`;
318
319 if (theme === 'register' && loginLinkPrefix)
320 {
321 message += `<p>Use the following link to verify this email address and complete your registration: <a href="${loginLinkPrefix}${password}">verify</a>.</p>\n`;
322 }
323 else if (theme === 'recover' && loginLinkPrefix)
324 {
325 message += `<p>Use the following link to log into the system to change your password: <a href="${loginLinkPrefix}${password}">login</a>.</p>\n`;
326 }
327 else
328 if (loginLinkPrefix)
329 {
330 message += `<p>Use the following link to log into the system: <a href="${loginLinkPrefix}${password}">login</a>.</p>\n`;
331 message += `<p>Alternatively, you can use the following password: ${password}</p>\n`;
332 message += `<p>Note: this link and password will expire within ${expireMinutes} minutes of request time.</p>\n`;
333 }
334 else
335 {
336 message += `<p>Use the following password to log into the system: ${password}</p>\n`;
337 message += `<p>Note: this password will expire within ${expireMinutes} minutes of request time.</p>\n`;
338 }
339 message += `<p>If this is not you, please contact system administrators.</p>\n`;
340 message += `<p>Kind Regards,</p>\n`;
341 message += `<p>${this.applicationName} Team</p>\n`;
342 message += `<p></p>\n`;
343 message += `<p>WARNING: This is an automaticly generated email. Do not reploy to it.</p>\n`;
344
345 return this.emailSender.send(to, this.fromAddress, subject, message);
346 }
347}
348
349module.exports = EmailAuth;