From e4f8b16717bc2fa044aefa31884690f8f0de56ce Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Tue, 1 Sep 2020 15:58:12 -0400 Subject: [PATCH] Add password reset flow --- api/src/controllers/auth.ts | 50 +++++++++++++++++++ api/src/mail/index.ts | 10 ++++ api/src/mail/templates/password-reset.liquid | 15 ++++++ api/src/mail/templates/user-invitation.liquid | 2 +- api/src/services/payload.ts | 2 +- api/src/services/users.ts | 47 ++++++++++++++--- app/src/api.ts | 7 ++- 7 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 api/src/mail/templates/password-reset.liquid diff --git a/api/src/controllers/auth.ts b/api/src/controllers/auth.ts index 8c3825192b..d345f3e566 100644 --- a/api/src/controllers/auth.ts +++ b/api/src/controllers/auth.ts @@ -10,6 +10,7 @@ import { InvalidPayloadException } from '../exceptions/invalid-payload'; import ms from 'ms'; import cookieParser from 'cookie-parser'; import env from '../env'; +import UsersService from '../services/users'; const router = Router(); @@ -153,6 +154,55 @@ router.post( }) ); +router.post( + '/password/request', + asyncHandler(async (req, res) => { + if (!req.body.email) { + throw new InvalidPayloadException(`"email" field is required.`); + } + + const accountability = { + ip: req.ip, + userAgent: req.get('user-agent'), + role: null, + }; + + const service = new UsersService({ accountability }); + + try { + await service.requestPasswordReset(req.body.email); + } catch { + // We don't want to give away what email addresses exist, so we'll always return a 200 + // from this endpoint + } finally { + return res.status(200).end(); + } + }) +) + +router.post( + '/password/reset', + asyncHandler(async (req, res) => { + if (!req.body.token) { + throw new InvalidPayloadException(`"token" field is required.`); + } + + if (!req.body.password) { + throw new InvalidPayloadException(`"password" field is required.`); + } + + const accountability = { + ip: req.ip, + userAgent: req.get('user-agent'), + role: null, + }; + + const service = new UsersService({ accountability }); + await service.resetPassword(req.body.token, req.body.password); + return res.status(200).end(); + }) +) + router.use( '/sso', session({ secret: env.SECRET as string, saveUninitialized: false, resave: false }) diff --git a/api/src/mail/index.ts b/api/src/mail/index.ts index 0a9a50a20f..565a1db276 100644 --- a/api/src/mail/index.ts +++ b/api/src/mail/index.ts @@ -65,3 +65,13 @@ export async function sendInviteMail(email: string, url: string) { const html = await liquidEngine.renderFile('user-invitation', { email, url, projectName }); await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html }); } + +export async function sendPasswordResetMail(email: string, url: string) { + /** + * @TODO pull this from directus_settings + */ + const projectName = 'directus'; + + const html = await liquidEngine.renderFile('password-reset', { email, url, projectName }); + await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html }); +} diff --git a/api/src/mail/templates/password-reset.liquid b/api/src/mail/templates/password-reset.liquid new file mode 100644 index 0000000000..7f4be38190 --- /dev/null +++ b/api/src/mail/templates/password-reset.liquid @@ -0,0 +1,15 @@ +{% layout "base" %} +{% block content %} + +

You requested to reset your password. Please click the link below to reset your password:

+ +

{{ url }}

+ +{% comment %} +@TODO +Make this white-labeled +{% endcomment %} + +

Love,
Directus

+ +{% endblock %} diff --git a/api/src/mail/templates/user-invitation.liquid b/api/src/mail/templates/user-invitation.liquid index eaba3a24a0..b70bdd0450 100644 --- a/api/src/mail/templates/user-invitation.liquid +++ b/api/src/mail/templates/user-invitation.liquid @@ -3,7 +3,7 @@

You have been invited to {{ projectName }}. Please click the link below to join:

-

{{ url }}

+

{{ url }}

{% comment %} @TODO diff --git a/api/src/services/payload.ts b/api/src/services/payload.ts index a2028962b5..e938f62410 100644 --- a/api/src/services/payload.ts +++ b/api/src/services/payload.ts @@ -91,7 +91,7 @@ export default class PayloadService { return value; }, async conceal(action, value) { - if (action === 'read') return '**********'; + if (action === 'read') return value ? '**********' : null; return value; } }; diff --git a/api/src/services/users.ts b/api/src/services/users.ts index a848a2717c..f684359761 100644 --- a/api/src/services/users.ts +++ b/api/src/services/users.ts @@ -1,10 +1,10 @@ import AuthService from './authentication'; import ItemsService from './items'; import jwt from 'jsonwebtoken'; -import { sendInviteMail } from '../mail'; +import { sendInviteMail, sendPasswordResetMail } from '../mail'; import database from '../database'; import argon2 from 'argon2'; -import { InvalidPayloadException } from '../exceptions'; +import { InvalidPayloadException, ForbiddenException } from '../exceptions'; import { Accountability, PrimaryKey, Item, AbstractServiceOptions } from '../types'; import Knex from 'knex'; import env from '../env'; @@ -48,7 +48,7 @@ export default class UsersService extends ItemsService { async inviteUser(email: string, role: string) { await this.service.create({ email, role, status: 'invited' }); - const payload = { email }; + const payload = { email, scope: 'invite' }; const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d' }); const acceptURL = env.PUBLIC_URL + '/admin/accept-invite?token=' + token; @@ -56,9 +56,11 @@ export default class UsersService extends ItemsService { } async acceptInvite(token: string, password: string) { - const { email } = jwt.verify(token, env.SECRET as string) as { email: string }; + const { email, scope } = jwt.verify(token, env.SECRET as string) as { email: string, scope: string }; - const user = await database + if (scope !== 'invite') throw new ForbiddenException(); + + const user = await this.knex .select('id', 'status') .from('directus_users') .where({ email }) @@ -70,7 +72,40 @@ export default class UsersService extends ItemsService { const passwordHashed = await argon2.hash(password); - await database('directus_users') + await this.knex('directus_users') + .update({ password: passwordHashed, status: 'active' }) + .where({ id: user.id }); + } + + async requestPasswordReset(email: string) { + const user = await this.knex.select('id').from('directus_users').where({ email }).first(); + if (!user) throw new ForbiddenException(); + + const payload = { email, scope: 'password-reset' }; + const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d', }); + const acceptURL = env.PUBLIC_URL + '/admin/reset-password?token=' + token; + + await sendPasswordResetMail(email, acceptURL); + } + + async resetPassword(token: string, password: string) { + const { email, scope } = jwt.verify(token, env.SECRET as string) as { email: string, scope: string }; + + if (scope !== 'password-reset') throw new ForbiddenException(); + + const user = await this.knex + .select('id', 'status') + .from('directus_users') + .where({ email }) + .first(); + + if (!user || user.status !== 'active') { + throw new ForbiddenException(); + } + + const passwordHashed = await argon2.hash(password); + + await this.knex('directus_users') .update({ password: passwordHashed, status: 'active' }) .where({ id: user.id }); } diff --git a/app/src/api.ts b/app/src/api.ts index 7e52ced096..2af8a3ec29 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -53,7 +53,12 @@ export const onError = async (error: RequestError) => { /* istanbul ignore next */ const code = error.response?.data?.errors?.[0]?.extensions?.code; - if (status === 401 && code === 'INVALID_CREDENTIALS' && error.request.responseURL.includes('refresh') === false) { + if ( + status === 401 && + code === 'INVALID_CREDENTIALS' && + error.request.responseURL.includes('refresh') === false && + error.request.responseURL.includes('login') === false + ) { try { const newToken = await refresh();