Skip to content

Commit 357da35

Browse files
Refactor TokenService and account manager, add tests
1 parent 412f488 commit 357da35

File tree

9 files changed

+352
-8
lines changed

9 files changed

+352
-8
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use strict'
2+
3+
/**
4+
* Returns a partial Email object (minus the `to` and `from` properties),
5+
* suitable for sending with Nodemailer.
6+
*
7+
* Used to send a Reset Password email, upon user request
8+
*
9+
* @param data {Object}
10+
*
11+
* @param data.resetUrl {string}
12+
* @param data.webId {string}
13+
*
14+
* @return {Object}
15+
*/
16+
function render (data) {
17+
return {
18+
subject: 'Account password reset',
19+
20+
/**
21+
* Text version
22+
*/
23+
text: `Hi,
24+
25+
We received a request to reset your password for your Solid account, ${data.webId}
26+
27+
To reset your password, click on the following link:
28+
29+
${data.resetUrl}
30+
31+
If you did not mean to reset your password, ignore this email, your password will not change.`,
32+
33+
/**
34+
* HTML version
35+
*/
36+
html: `<p>Hi,</p>
37+
38+
<p>We received a request to reset your password for your Solid account, ${data.webId}</p>
39+
40+
<p>To reset your password, click on the following link:</p>
41+
42+
<p>${data.resetUrl}</p>
43+
44+
<p>If you did not mean to reset your password, ignore this email, your password will not change.</p>
45+
`
46+
}
47+
}
48+
49+
module.exports.render = render

lib/account-recovery.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module.exports = AccountRecovery
22

33
const express = require('express')
4-
const TokenService = require('./token-service')
4+
const TokenService = require('./models/token-service')
55
const bodyParser = require('body-parser')
66
const path = require('path')
77
const debug = require('debug')('solid:account-recovery')

lib/create-app.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const SolidHost = require('./models/solid-host')
1212
const AccountManager = require('./models/account-manager')
1313
const vhost = require('vhost')
1414
const EmailService = require('./models/email-service')
15+
const TokenService = require('./models/token-service')
1516
const AccountRecovery = require('./account-recovery')
1617
const capabilityDiscovery = require('./capability-discovery')
1718
const bodyParser = require('body-parser').urlencoded({ extended: false })
@@ -90,6 +91,7 @@ function initAppLocals (app, argv, ldp) {
9091
app.locals.appUrls = argv.apps // used for service capability discovery
9192
app.locals.host = argv.host
9293
app.locals.authMethod = argv.auth
94+
app.locals.tokenService = new TokenService()
9395

9496
if (argv.email && argv.email.host) {
9597
app.locals.emailService = new EmailService(argv.templates.email, argv.email)
@@ -152,6 +154,7 @@ function initWebId (argv, app, ldp) {
152154
let accountManager = AccountManager.from({
153155
authMethod: argv.auth,
154156
emailService: app.locals.emailService,
157+
tokenService: app.locals.tokenService,
155158
host: argv.host,
156159
accountTemplatePath: argv.templates.account,
157160
store: ldp,

lib/models/account-manager.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class AccountManager {
2424
* @param [options={}] {Object}
2525
* @param [options.authMethod] {string} Primary authentication method (e.g. 'tls')
2626
* @param [options.emailService] {EmailService}
27+
* @param [options.tokenService] {TokenService}
2728
* @param [options.host] {SolidHost}
2829
* @param [options.multiUser=false] {boolean} (argv.idp) Is the server running
2930
* in multiUser mode (users can sign up for accounts) or single user
@@ -41,6 +42,7 @@ class AccountManager {
4142
}
4243
this.host = options.host
4344
this.emailService = options.emailService
45+
this.tokenService = options.tokenService
4446
this.authMethod = options.authMethod || defaults.auth
4547
this.multiUser = options.multiUser || false
4648
this.store = options.store
@@ -200,6 +202,22 @@ class AccountManager {
200202
return webIdUri.format()
201203
}
202204

205+
/**
206+
* Returns the root .acl URI for a given user account (the account recovery
207+
* email is stored there).
208+
*
209+
* @param userAccount {UserAccount}
210+
*
211+
* @throws {Error} via accountUriFor()
212+
*
213+
* @return {string} Root .acl URI
214+
*/
215+
rootAclFor (userAccount) {
216+
let accountUri = this.accountUriFor(userAccount.username)
217+
218+
return url.resolve(accountUri, this.store.suffixAcl)
219+
}
220+
203221
/**
204222
* Adds a newly generated WebID-TLS certificate to the user's profile graph.
205223
*
@@ -352,6 +370,91 @@ class AccountManager {
352370
})
353371
}
354372

373+
/**
374+
* Generates an expiring one-time-use token for password reset purposes
375+
* (the user's Web ID is saved in the token service).
376+
*
377+
* @param userAccount {UserAccount}
378+
*
379+
* @return {string} Generated token
380+
*/
381+
generateResetToken (userAccount) {
382+
return this.tokenService.generate({ webId: userAccount.webId })
383+
}
384+
385+
/**
386+
* Returns a password reset URL (to be emailed to the user upon request)
387+
*
388+
* @param token {string} One-time-use expiring token, via the TokenService
389+
* @param returnToUrl {string}
390+
*
391+
* @return {string}
392+
*/
393+
passwordResetUrl (token, returnToUrl) {
394+
let encodedReturnTo = encodeURIComponent(returnToUrl)
395+
396+
let resetUrl = url.resolve(this.host.serverUri,
397+
`/api/password/validateReset?token=${token}` +
398+
`&returnToUrl=${encodedReturnTo}`)
399+
400+
return resetUrl
401+
}
402+
403+
/**
404+
* Parses and returns an account recovery email stored in a user's root .acl
405+
*
406+
* @param userAccount {UserAccount}
407+
*
408+
* @return {Promise<string|undefined>}
409+
*/
410+
loadAccountRecoveryEmail (userAccount) {
411+
return Promise.resolve()
412+
.then(() => {
413+
let rootAclUri = this.rootAclFor(userAccount)
414+
415+
return this.store.getGraph(rootAclUri)
416+
})
417+
.then(rootAclGraph => {
418+
let matches = rootAclGraph.match(null, ns.acl('agent'))
419+
420+
let recoveryMailto = matches.find(agent => {
421+
return agent.object.value.startsWith('mailto:')
422+
})
423+
424+
if (recoveryMailto) {
425+
recoveryMailto = recoveryMailto.object.value.replace('mailto:', '')
426+
}
427+
428+
return recoveryMailto
429+
})
430+
}
431+
432+
sendPasswordResetEmail (userAccount, returnToUrl) {
433+
return Promise.resolve()
434+
.then(() => {
435+
if (!this.emailService) {
436+
throw new Error('Email service is not set up')
437+
}
438+
439+
if (!userAccount.email) {
440+
throw new Error('Account recovery email has not been provided')
441+
}
442+
443+
return this.generateResetToken(userAccount)
444+
})
445+
.then(resetToken => {
446+
let resetUrl = this.passwordResetUrl(resetToken, returnToUrl)
447+
448+
let emailData = {
449+
to: userAccount.email,
450+
webId: userAccount.webId,
451+
resetUrl
452+
}
453+
454+
return this.emailService.sendWithTemplate('reset-password', emailData)
455+
})
456+
}
457+
355458
/**
356459
* Sends a Welcome email (on new user signup).
357460
*

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
"node-mocks-http": "^1.5.6",
8585
"nyc": "^10.1.2",
8686
"proxyquire": "^1.7.10",
87-
"sinon": "^1.17.7",
87+
"sinon": "^2.1.0",
8888
"sinon-chai": "^2.8.0",
8989
"standard": "^8.6.0",
9090
"supertest": "^1.2.0"

0 commit comments

Comments
 (0)