From 45bbde1037aceb5f1f4fd85a2fdf7a91750daede Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Fri, 26 Feb 2021 15:20:13 +0100 Subject: [PATCH] api: Registration, password recovery, verification flows --- api/src/_middleware/validate-request.js | 6 +- api/src/accounts/account.service.js | 12 +- api/src/config/oauth2orize.js | 0 api/src/config/passport.js | 11 +- api/src/index.js | 6 +- api/src/routes/auth.js | 140 +++++++++++++++++++++++- api/views/forgot-password.pug | 12 ++ api/views/layout.pug | 37 +++++++ api/views/login.pug | 6 +- api/views/logout.pug | 8 ++ api/views/message.pug | 3 + api/views/register.pug | 25 +++++ api/views/reset-password.pug | 18 +++ 13 files changed, 264 insertions(+), 20 deletions(-) delete mode 100644 api/src/config/oauth2orize.js create mode 100644 api/views/forgot-password.pug create mode 100644 api/views/logout.pug create mode 100644 api/views/register.pug create mode 100644 api/views/reset-password.pug diff --git a/api/src/_middleware/validate-request.js b/api/src/_middleware/validate-request.js index be64dc5..7222023 100644 --- a/api/src/_middleware/validate-request.js +++ b/api/src/_middleware/validate-request.js @@ -1,4 +1,4 @@ -const validateRequest = (schema) => (req, res, next) => { +const validateRequest = (schema, property = 'body') => (req, res, next) => { console.log('validateRequest'); const options = { @@ -6,12 +6,12 @@ const validateRequest = (schema) => (req, res, next) => { allowUnknown: true, // ignore unknown props stripUnknown: true, // remove unknown props }; - const { error, value } = schema.validate(req.body, options); + const { error, value } = schema.validate(req[property], options); if (error) { console.log('error: ', error); next(`Validation error: ${error.details.map((x) => x.message).join(', ')}`); } else { - req.body = value; + req[property] = value; next(); } }; diff --git a/api/src/accounts/account.service.js b/api/src/accounts/account.service.js index 5d88b28..d8b4843 100644 --- a/api/src/accounts/account.service.js +++ b/api/src/accounts/account.service.js @@ -100,11 +100,11 @@ function randomTokenString() { async function sendVerificationEmail(account, origin) { let message; if (origin) { - const verifyUrl = `${origin}/account/verify-email?token=${account.verificationToken}`; + const verifyUrl = `${origin}/verify-email?token=${account.verificationToken}`; message = `

Please click the below link to verify your email address:

${verifyUrl}

`; } else { - message = `

Please use the below token to verify your email address with the /account/verify-email api route:

+ message = `

Please use the below token to verify your email address with the /verify-email api route:

${account.verificationToken}

`; } @@ -120,9 +120,9 @@ async function sendVerificationEmail(account, origin) { async function sendAlreadyRegisteredEmail(email, origin) { let message; if (origin) { - message = `

If you don't know your password please visit the forgot password page.

`; + message = `

If you don't know your password please visit the forgot password page.

`; } else { - message = `

If you don't know your password you can reset it via the /account/forgot-password api route.

`; + message = `

If you don't know your password you can reset it via the /forgot-password api route.

`; } await sendEmail({ @@ -137,11 +137,11 @@ async function sendAlreadyRegisteredEmail(email, origin) { async function sendPasswordResetEmail(account, origin) { let message; if (origin) { - const resetUrl = `${origin}/account/reset-password?token=${account.resetToken.token}`; + const resetUrl = `${origin}/reset-password?token=${account.resetToken.token}`; message = `

Please click the below link to reset your password, the link will be valid for 1 day:

${resetUrl}

`; } else { - message = `

Please use the below token to reset your password with the /account/reset-password api route:

+ message = `

Please use the below token to reset your password with the /reset-password api route:

${account.resetToken.token}

`; } diff --git a/api/src/config/oauth2orize.js b/api/src/config/oauth2orize.js deleted file mode 100644 index e69de29..0000000 diff --git a/api/src/config/passport.js b/api/src/config/passport.js index f3daf41..644a645 100644 --- a/api/src/config/passport.js +++ b/api/src/config/passport.js @@ -24,7 +24,13 @@ async function loginWithPassword(email, password, done) { try { const user = await User.findOne({ email: email }); if (!user || !user.validPassword(password)) { - return done(null, false, { errors: { 'email or password': 'is invalid' } }); + return done(new Error('invalid credentials'), false); + } + + // Regardless of whether login is required, if you're logged in as an + // unverified user, produce an error. + if (user.needsEmailValidation) { + return done(new Error('email not verified'), false); } return done(null, user); @@ -117,7 +123,7 @@ passport.use( const refreshToken = await RefreshToken.findOne({ token }).populate('user'); if (refreshToken && refreshToken.user) { // TODO: scope - return done(null, user, { scope: 'auth.refresh' }); + return done(null, refreshToken.user, { scope: 'auth.refresh' }); } else { return done(null, false); } @@ -181,7 +187,6 @@ function createMiddleware(strategies, required = true, session = false) { } req.user = user; - req.authInfo = info; req.scope = (info && info.scope) || '*'; return next(); diff --git a/api/src/index.js b/api/src/index.js index 01831f9..4dcd326 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -16,8 +16,8 @@ const app = express(); app.use(cors()); // Express configuration -app.set('views', './views') -app.set('view engine', 'pug') +app.set('views', './views'); +app.set('view engine', 'pug'); // Normal express config defaults app.use(require('morgan')('dev')); @@ -27,7 +27,7 @@ app.use(bodyParser.urlencoded({ limit: '50mb', extended: false })); app.use(require('method-override')()); app.use(express.static(path.join(__dirname, 'public'))); -app.use(session({ secret: 'obsobs', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false })); +app.use(session({ secret: 'obsobs', cookie: { maxAge: 10 * 60 * 1000 }, resave: false, saveUninitialized: false })); app.use(passport.initialize()); app.use(passport.session()); diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js index ca498f8..8c6c6c7 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -4,6 +4,7 @@ const { URL } = require('url'); const { createChallenge } = require('pkce'); const { AuthorizationCode, AccessToken, RefreshToken, Client } = require('../models'); +const auth = require('../config/passport'); const wrapRoute = require('../_helpers/wrapRoute'); // Check whether the "bigScope" fully includes the "smallScope". @@ -45,9 +46,30 @@ function isValidScope(scope) { return scope === '*' || scopeIncludes(scope, ALL_SCOPE_NAMES.join(' ')); } +router.use((req, res, next) => { + res.locals.user = req.user; + next(); +}); + router.post( '/login', - passport.authenticate('usernameAndPasswordSession'), + passport.authenticate('usernameAndPasswordSession', { session: true }), + (err, req, res, next) => { + if (!err) { + next(); + } + + if (err.message === 'invalid credentials') { + return res.render('login', { badCredentials: true }); + } + + let description = 'Unknown error while processing your login.'; + if (err.message === 'email not verified') { + description = 'Your account is not yet verified, please check your email or start the password recovery.'; + } + + return res.render('message', { type: 'error', title: 'Login failed', description }); + }, wrapRoute((req, res, next) => { if (!req.user) { return res.redirect('/login'); @@ -69,10 +91,21 @@ router.get( return res.render('message', { type: 'success', title: 'You are already logged in.' }); } - res.render('login'); + return res.render('login'); }), ); +router + .route('/logout') + .post( + auth.usernameAndPasswordSession, + wrapRoute(async (req, res) => { + req.logout(); + return res.redirect('/login'); + }), + ) + .get((req, res) => res.render('logout')); + const isIp = (ip) => typeof ip === 'string' && /^([0-9]{1,3}\.)[0-9]{1,3}$/.test(ip) && @@ -115,7 +148,6 @@ router.get( passport.authenticate('session'), wrapRoute(async (req, res) => { if (!req.user) { - console.log(req); req.session.next = req.url; return res.redirect('/login'); } @@ -208,7 +240,6 @@ router.get( res.render('authorize', { clientTitle: client.title, scope, redirectUri }); } catch (err) { - console.error(err); res.status(400).json({ error: 'invalid_request', error_description: 'unknown error' }); } }), @@ -385,3 +416,104 @@ router.get( ); module.exports = router; + +const accountService = require('../accounts/account.service'); +const validateRequest = require('../_middleware/validate-request'); +const Joi = require('joi'); + +router + .route('/register') + .post( + validateRequest( + Joi.object({ + username: Joi.string().required(), + email: Joi.string().email().required(), + password: Joi.string().min(6).required(), + confirmPassword: Joi.string().valid(Joi.ref('password')).required(), + }), + ), + wrapRoute(async (req, res) => { + await accountService.register(req.body, req.get('origin')); + + return res.render('message', { + type: 'success', + title: 'Registration successful', + description: 'Please check your email for verification instructions.', + }); + }), + ) + .get((req, res) => res.render('register')); + +router.get( + '/verify-email', + validateRequest( + Joi.object({ + token: Joi.string().required(), + }), + 'query', + ), + wrapRoute(async (req, res) => { + await accountService.verifyEmail(req.query); + return res.render('message', { + type: 'success', + title: 'Verification successful', + description: 'You can now log in.', + showLoginButton: true, + }); + }), +); + +router + .route('/forgot-password') + .post( + validateRequest( + Joi.object({ + email: Joi.string().email().required(), + }), + ), + wrapRoute(async (req, res) => { + await accountService.forgotPassword(req.body, req.get('origin')); + res.render('message', { + type: 'success', + title: 'Recovery mail sent', + description: 'Please check your inbox for password recovery instructions.', + }); + }), + ) + .get((req, res) => res.render('forgot-password')); + +router + .route('/reset-password') + .post( + validateRequest( + Joi.object({ + token: Joi.string().required(), + password: Joi.string().min(6).required(), + confirmPassword: Joi.string().valid(Joi.ref('password')).required(), + }), + ), + wrapRoute(async (req, res) => { + await accountService.resetPassword(req.body); + return res.render('message', { + type: 'success', + title: 'Password reset successful', + description: 'You can now log in.', + showLoginButton: true, + }); + }), + ) + .get( + validateRequest( + Joi.object({ + token: Joi.string().required(), + }), + 'query', + ), + wrapRoute(async (req, res) => { + const { token } = req.query; + await accountService.validateResetToken({ token }); + res.render('reset-password', { token }); + }), + ); + +module.exports = router; diff --git a/api/views/forgot-password.pug b/api/views/forgot-password.pug new file mode 100644 index 0000000..dba5152 --- /dev/null +++ b/api/views/forgot-password.pug @@ -0,0 +1,12 @@ +extends layout.pug + +block title + | Reset Password + +block content + form(method="post") + fieldset + label(for="email") E-Mail Address + input(id="email", name="email") + + button(type="submit") Send recovery mail diff --git a/api/views/layout.pug b/api/views/layout.pug index 8a4a77b..b862e67 100644 --- a/api/views/layout.pug +++ b/api/views/layout.pug @@ -97,8 +97,45 @@ html background: #83d283; } + nav ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + align-items: center; + padding-bottom: 1rem; + margin-bottom: 1rem; + border-bottom: 1px solid #DDD; + } + nav ul li { + flex: 1 1 0; + text-align: center; + } + nav ul li a { + color: #999; + text-decoration: none; + } + nav ul li a:hover { + color: #444; + } + + nav header { + text-align: center; + font-weight: 500; + margin-bottom: 1rem; + } + body main + nav + header OpenBikeSensor Account Pages + ul + li: a(href="/") Back to Portal + if !user + li: a(href="/login") Login + li: a(href="/register") Register + if user + li: a(href="/logout") Logout header: h1 block title block content diff --git a/api/views/login.pug b/api/views/login.pug index f697af5..0c646d0 100644 --- a/api/views/login.pug +++ b/api/views/login.pug @@ -13,4 +13,8 @@ block content label(for="password") Password input(name="password", type="password") - button(type="submit") Login + if badCredentials + p.message.error Invalid login credentials, please try again. + + button(type="submit", style="margin-right: 2rem") Login + a(href="/forgot-password") I forgot my password diff --git a/api/views/logout.pug b/api/views/logout.pug new file mode 100644 index 0000000..1d899e1 --- /dev/null +++ b/api/views/logout.pug @@ -0,0 +1,8 @@ +extends layout.pug + +block title + | Logout + +block content + form(method="post", action="/logout") + button(type="submit", class="red") Log out now diff --git a/api/views/message.pug b/api/views/message.pug index 582b72f..637e2dc 100644 --- a/api/views/message.pug +++ b/api/views/message.pug @@ -6,3 +6,6 @@ block title block content if description div(class="message " + type)= description + + if showLoginButton + p: a(href="/login") Go to login diff --git a/api/views/register.pug b/api/views/register.pug new file mode 100644 index 0000000..5735f85 --- /dev/null +++ b/api/views/register.pug @@ -0,0 +1,25 @@ +extends layout.pug + +block title + | Register + +block content + form(method="post") + fieldset + label(for="username") Username + input(id="username", name="username") + p.form-hint At least 3 characters, alphanumerical, must be unique + + fieldset + label(for="email") E-Mail Address + input(id="email", name="email", type="email") + + fieldset + label(for="password") Password + input(id="password", name="password", type="password") + + fieldset + label(for="confirmPassword") Confirm Password + input(id="confirmPassword", name="confirmPassword", type="password") + + button(type="submit") Register diff --git a/api/views/reset-password.pug b/api/views/reset-password.pug new file mode 100644 index 0000000..4ca1695 --- /dev/null +++ b/api/views/reset-password.pug @@ -0,0 +1,18 @@ +extends layout.pug + +block title + | Reset Password + +block content + form(method="post") + input(type="hidden", name="token", value=token) + + fieldset + label(for="password") New Password + input(id="password", name="password", type="password") + + fieldset + label(for="confirmPassword") Confirm Password + input(id="confirmPassword", name="confirmPassword", type="password") + + button(type="submit") Set new password