diff --git a/.envdev b/.envdev index dc6fe19..64c7d09 100644 --- a/.envdev +++ b/.envdev @@ -1,5 +1,6 @@ WEB_PROTOCOL=http WEB_ADDRESS=localhost +WEB_RESET_ADDRESS=localhost/reset WEB_PORT=3200 DB_HOSTNAME=database diff --git a/README.md b/README.md index 9a33e8e..22b440e 100644 --- a/README.md +++ b/README.md @@ -83,4 +83,28 @@ Content-Type: application/json { "password": "helloworld" } + + +//DOCS: Send the link to recover a forgotten password +POST /auth/recover +Content-Type: application/json + +{ + "email": "kayneruse@gmail.com" +} + + +//DOCS: Redirect the link to recover a password to the front-end +GET /auth/reset?token= + +//Result +301 -> ${WEB_RESET_ADDRESS}?email=&token= + + +//DOCS: Resets a password for the given email, correct token is required +PATCH /auth/reset?email=&token= + +{ + "password": "password" +} ``` diff --git a/configure-script.js b/configure-script.js index d06c014..0e5f16b 100644 --- a/configure-script.js +++ b/configure-script.js @@ -30,6 +30,7 @@ const question = (prompt, def = null) => { //project configuration const appName = await question('App Name', 'auth'); const appWebAddress = await question('Web Addr', `${appName}.example.com`); + const resetAddress = await question('Reset Addr', `example.com/reset`); const appPort = await question('App Port', '3200'); const appDBUser = await question('DB User', appName); @@ -69,6 +70,7 @@ services: environment: - WEB_PROTOCOL=https - WEB_ADDRESS=${appWebAddress} + - WEB_RESET_ADDRESS=${resetAddress} - WEB_PORT=${appPort} - DB_HOSTNAME=database - DB_DATABASE=${appName} diff --git a/server/auth/index.js b/server/auth/index.js index 57ca88a..8ee96fb 100644 --- a/server/auth/index.js +++ b/server/auth/index.js @@ -14,6 +14,11 @@ router.post('/login', require('./login')); //refresh token router.post('/token', require('./token')); +//password recover and reset +router.post('/recover', require('./password-recover')); +router.get('/reset', require('./password-redirect')); +router.patch('/reset', require('./password-reset')); + //middleware router.use(tokenAuth); diff --git a/server/auth/password-recover.js b/server/auth/password-recover.js new file mode 100644 index 0000000..a5c4bb6 --- /dev/null +++ b/server/auth/password-recover.js @@ -0,0 +1,106 @@ +//libraries +const nodemailer = require('nodemailer'); + +const { accounts, recovery } = require('../database/models'); + +//utilities +const uuid = require('../utilities/uuid'); +const validateEmail = require('../utilities/validate-email'); + +//auth/recover +const route = async (req, res) => { + //validate details + const validateErr = await validateDetails(req.body); + if (validateErr) { + return res.status(401).end(validateErr); + } + + //recovery token + const token = uuid(32); + + //send the recovery email + const emailErr = await sendRecoveryEmail(req.body.email, token); + if (emailErr) { + return res.status(500).send(emailErr); + } + + //save the token + recovery.upsert({ + email: req.body.email, + token: token + }); + + //finally + res.status(200).send("Validation email sent!"); + return null; +}; + +const validateDetails = async (body) => { + //basic formatting + if (!validateEmail(body.email)) { + return 'Invalid email'; + } + + //check for existing email + const emailRecord = await accounts.findOne({ + where: { + email: body.email + } + }); + + if (!emailRecord) { + return 'Invalid email'; + } + + //OK + return null; +}; + +const sendRecoveryEmail = async (email, token) => { + const addr = `${process.env.WEB_PROTOCOL}://${process.env.WEB_ADDRESS}/auth/validation?token=${token}`; + const msg = `Hello, + +Please visit the following link to reset your password: ${addr} + +If you did not request a password reset, you can safely ignore this message. +`; + + let transporter, info; + + //what exactly is a transport? + try { + transporter = nodemailer.createTransport({ + host: process.env.MAIL_SMTP, + port: 465, + secure: true, + auth: { + user: process.env.MAIL_USERNAME, + pass: process.env.MAIL_PASSWORD + }, + }); + } + catch(e) { + return `failed to create a mail transport: ${e}`; + } + + // send mail with defined transport object + try { + info = await transporter.sendMail({ + from: `recovery@${process.env.WEB_ADDRESS}`, //WARNING: google overwrites this + to: email, + subject: 'Password Recovery', + text: msg + }); + } + catch(e) { + return `failed to send validation mail: ${e}`; + } + + if (info.accepted[0] != email) { + return 'recovery email failed to send'; + } + + return null; +}; + +module.exports = route; \ No newline at end of file diff --git a/server/auth/password-redirect.js b/server/auth/password-redirect.js new file mode 100644 index 0000000..e8b8fe4 --- /dev/null +++ b/server/auth/password-redirect.js @@ -0,0 +1,19 @@ +const { accounts, recovery } = require('../database/models'); + +//auth/reset +const route = async (req, res) => { + //verify the recovery record exists + const record = recovery.findOne({ + token: req.query.token + }); + + if (!record) { + return res.status(401).end('Failed to recover a password'); + } + + //redirect to the front-end + res.redirect(`${process.env.WEB_PROTOCOL}${process.env.WEB_RESET_ADDRESS}?email=${record.email}&token=${record.token}`); + return null; +}; + +module.exports = route; \ No newline at end of file diff --git a/server/auth/password-reset.js b/server/auth/password-reset.js new file mode 100644 index 0000000..16d24d1 --- /dev/null +++ b/server/auth/password-reset.js @@ -0,0 +1,59 @@ +//libraries +const bcrypt = require('bcryptjs'); + +const { accounts, recovery } = require('../database/models'); + +//auth/reset +const route = async (req, res) => { + //validate the given details + const validateErr = await validateDetails(req.query, req.body); + if (validateErr) { + return res.status(401).send(validateErr); + } + + //generate the password hash + const hash = await bcrypt.hash(req.body.password, await bcrypt.genSalt(11)); + + //update the account data + accounts.update({ + hash: hash + }, { + where: { + email: req.query.email + } + }) + + //delete from the recovery table + recovery.destroy({ + where: { + email: req.query.email + } + }); + + return null; +}; + +const validateDetails = async (query, body) => { + //verify the recovery record exists + const record = recovery.findOne({ + email: query.email, + token: query.token + }); + + if (!record) { + return 'Failed to recover a password'; + } + + //validate password + if (!body.password) { + return 'Missing password'; + } + + if (body.password.length < 8) { + return 'Password too short'; + } + + return null; +}; + +module.exports = route; \ No newline at end of file diff --git a/server/database/models/index.js b/server/database/models/index.js index eadaaea..264f723 100644 --- a/server/database/models/index.js +++ b/server/database/models/index.js @@ -1,5 +1,6 @@ module.exports = { tokens: require('./tokens'), accounts: require('./accounts'), - pendingSignups: require('./pending-signups') + pendingSignups: require('./pending-signups'), + recovery: require('./recovery') }; \ No newline at end of file diff --git a/server/database/models/recovery.js b/server/database/models/recovery.js new file mode 100644 index 0000000..08eef80 --- /dev/null +++ b/server/database/models/recovery.js @@ -0,0 +1,6 @@ +const sequelize = require('..'); + +module.exports = sequelize.define('recovery', { + token: 'varchar(320)', + email: 'varchar(320)' +});