api: Registration, password recovery, verification flows

This commit is contained in:
Paul Bienkowski 2021-02-26 15:20:13 +01:00
parent d1d7921808
commit 45bbde1037
13 changed files with 264 additions and 20 deletions

View file

@ -1,4 +1,4 @@
const validateRequest = (schema) => (req, res, next) => { const validateRequest = (schema, property = 'body') => (req, res, next) => {
console.log('validateRequest'); console.log('validateRequest');
const options = { const options = {
@ -6,12 +6,12 @@ const validateRequest = (schema) => (req, res, next) => {
allowUnknown: true, // ignore unknown props allowUnknown: true, // ignore unknown props
stripUnknown: true, // remove 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) { if (error) {
console.log('error: ', error); console.log('error: ', error);
next(`Validation error: ${error.details.map((x) => x.message).join(', ')}`); next(`Validation error: ${error.details.map((x) => x.message).join(', ')}`);
} else { } else {
req.body = value; req[property] = value;
next(); next();
} }
}; };

View file

@ -100,11 +100,11 @@ function randomTokenString() {
async function sendVerificationEmail(account, origin) { async function sendVerificationEmail(account, origin) {
let message; let message;
if (origin) { if (origin) {
const verifyUrl = `${origin}/account/verify-email?token=${account.verificationToken}`; const verifyUrl = `${origin}/verify-email?token=${account.verificationToken}`;
message = `<p>Please click the below link to verify your email address:</p> message = `<p>Please click the below link to verify your email address:</p>
<p><a href="${verifyUrl}">${verifyUrl}</a></p>`; <p><a href="${verifyUrl}">${verifyUrl}</a></p>`;
} else { } else {
message = `<p>Please use the below token to verify your email address with the <code>/account/verify-email</code> api route:</p> message = `<p>Please use the below token to verify your email address with the <code>/verify-email</code> api route:</p>
<p><code>${account.verificationToken}</code></p>`; <p><code>${account.verificationToken}</code></p>`;
} }
@ -120,9 +120,9 @@ async function sendVerificationEmail(account, origin) {
async function sendAlreadyRegisteredEmail(email, origin) { async function sendAlreadyRegisteredEmail(email, origin) {
let message; let message;
if (origin) { if (origin) {
message = `<p>If you don't know your password please visit the <a href="${origin}/account/forgot-password">forgot password</a> page.</p>`; message = `<p>If you don't know your password please visit the <a href="${origin}/forgot-password">forgot password</a> page.</p>`;
} else { } else {
message = `<p>If you don't know your password you can reset it via the <code>/account/forgot-password</code> api route.</p>`; message = `<p>If you don't know your password you can reset it via the <code>/forgot-password</code> api route.</p>`;
} }
await sendEmail({ await sendEmail({
@ -137,11 +137,11 @@ async function sendAlreadyRegisteredEmail(email, origin) {
async function sendPasswordResetEmail(account, origin) { async function sendPasswordResetEmail(account, origin) {
let message; let message;
if (origin) { if (origin) {
const resetUrl = `${origin}/account/reset-password?token=${account.resetToken.token}`; const resetUrl = `${origin}/reset-password?token=${account.resetToken.token}`;
message = `<p>Please click the below link to reset your password, the link will be valid for 1 day:</p> message = `<p>Please click the below link to reset your password, the link will be valid for 1 day:</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>`; <p><a href="${resetUrl}">${resetUrl}</a></p>`;
} else { } else {
message = `<p>Please use the below token to reset your password with the <code>/account/reset-password</code> api route:</p> message = `<p>Please use the below token to reset your password with the <code>/reset-password</code> api route:</p>
<p><code>${account.resetToken.token}</code></p>`; <p><code>${account.resetToken.token}</code></p>`;
} }

View file

@ -24,7 +24,13 @@ async function loginWithPassword(email, password, done) {
try { try {
const user = await User.findOne({ email: email }); const user = await User.findOne({ email: email });
if (!user || !user.validPassword(password)) { 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); return done(null, user);
@ -117,7 +123,7 @@ passport.use(
const refreshToken = await RefreshToken.findOne({ token }).populate('user'); const refreshToken = await RefreshToken.findOne({ token }).populate('user');
if (refreshToken && refreshToken.user) { if (refreshToken && refreshToken.user) {
// TODO: scope // TODO: scope
return done(null, user, { scope: 'auth.refresh' }); return done(null, refreshToken.user, { scope: 'auth.refresh' });
} else { } else {
return done(null, false); return done(null, false);
} }
@ -181,7 +187,6 @@ function createMiddleware(strategies, required = true, session = false) {
} }
req.user = user; req.user = user;
req.authInfo = info;
req.scope = (info && info.scope) || '*'; req.scope = (info && info.scope) || '*';
return next(); return next();

View file

@ -16,8 +16,8 @@ const app = express();
app.use(cors()); app.use(cors());
// Express configuration // Express configuration
app.set('views', './views') app.set('views', './views');
app.set('view engine', 'pug') app.set('view engine', 'pug');
// Normal express config defaults // Normal express config defaults
app.use(require('morgan')('dev')); app.use(require('morgan')('dev'));
@ -27,7 +27,7 @@ app.use(bodyParser.urlencoded({ limit: '50mb', extended: false }));
app.use(require('method-override')()); app.use(require('method-override')());
app.use(express.static(path.join(__dirname, 'public'))); 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.initialize());
app.use(passport.session()); app.use(passport.session());

View file

@ -4,6 +4,7 @@ const { URL } = require('url');
const { createChallenge } = require('pkce'); const { createChallenge } = require('pkce');
const { AuthorizationCode, AccessToken, RefreshToken, Client } = require('../models'); const { AuthorizationCode, AccessToken, RefreshToken, Client } = require('../models');
const auth = require('../config/passport');
const wrapRoute = require('../_helpers/wrapRoute'); const wrapRoute = require('../_helpers/wrapRoute');
// Check whether the "bigScope" fully includes the "smallScope". // Check whether the "bigScope" fully includes the "smallScope".
@ -45,9 +46,30 @@ function isValidScope(scope) {
return scope === '*' || scopeIncludes(scope, ALL_SCOPE_NAMES.join(' ')); return scope === '*' || scopeIncludes(scope, ALL_SCOPE_NAMES.join(' '));
} }
router.use((req, res, next) => {
res.locals.user = req.user;
next();
});
router.post( router.post(
'/login', '/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) => { wrapRoute((req, res, next) => {
if (!req.user) { if (!req.user) {
return res.redirect('/login'); return res.redirect('/login');
@ -69,10 +91,21 @@ router.get(
return res.render('message', { type: 'success', title: 'You are already logged in.' }); 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) => const isIp = (ip) =>
typeof ip === 'string' && typeof ip === 'string' &&
/^([0-9]{1,3}\.)[0-9]{1,3}$/.test(ip) && /^([0-9]{1,3}\.)[0-9]{1,3}$/.test(ip) &&
@ -115,7 +148,6 @@ router.get(
passport.authenticate('session'), passport.authenticate('session'),
wrapRoute(async (req, res) => { wrapRoute(async (req, res) => {
if (!req.user) { if (!req.user) {
console.log(req);
req.session.next = req.url; req.session.next = req.url;
return res.redirect('/login'); return res.redirect('/login');
} }
@ -208,7 +240,6 @@ router.get(
res.render('authorize', { clientTitle: client.title, scope, redirectUri }); res.render('authorize', { clientTitle: client.title, scope, redirectUri });
} catch (err) { } catch (err) {
console.error(err);
res.status(400).json({ error: 'invalid_request', error_description: 'unknown error' }); res.status(400).json({ error: 'invalid_request', error_description: 'unknown error' });
} }
}), }),
@ -385,3 +416,104 @@ router.get(
); );
module.exports = router; 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;

View file

@ -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

View file

@ -97,8 +97,45 @@ html
background: #83d283; 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 body
main 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 header: h1
block title block title
block content block content

View file

@ -13,4 +13,8 @@ block content
label(for="password") Password label(for="password") Password
input(name="password", type="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

8
api/views/logout.pug Normal file
View file

@ -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

View file

@ -6,3 +6,6 @@ block title
block content block content
if description if description
div(class="message " + type)= description div(class="message " + type)= description
if showLoginButton
p: a(href="/login") Go to login

25
api/views/register.pug Normal file
View file

@ -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

View file

@ -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