mirror of
https://github.com/directus/directus.git
synced 2026-01-23 11:07:56 -05:00
Add password reset flow
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
15
api/src/mail/templates/password-reset.liquid
Normal file
15
api/src/mail/templates/password-reset.liquid
Normal file
@@ -0,0 +1,15 @@
|
||||
{% layout "base" %}
|
||||
{% block content %}
|
||||
|
||||
<p>You requested to reset your password. Please click the link below to reset your password:</p>
|
||||
|
||||
<p><a href="{{ url }}">{{ url }}</a></p>
|
||||
|
||||
{% comment %}
|
||||
@TODO
|
||||
Make this white-labeled
|
||||
{% endcomment %}
|
||||
|
||||
<p>Love,<br>Directus</p>
|
||||
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<p>You have been invited to {{ projectName }}. Please click the link below to join:</p>
|
||||
|
||||
<p><a href="url">{{ url }}</a></p>
|
||||
<p><a href="{{ url }}">{{ url }}</a></p>
|
||||
|
||||
{% comment %}
|
||||
@TODO
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user