api: Registration, password recovery, verification flows
This commit is contained in:
parent
d1d7921808
commit
45bbde1037
|
@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
12
api/views/forgot-password.pug
Normal file
12
api/views/forgot-password.pug
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
8
api/views/logout.pug
Normal 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
|
|
@ -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
25
api/views/register.pug
Normal 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
|
18
api/views/reset-password.pug
Normal file
18
api/views/reset-password.pug
Normal 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
|
Loading…
Reference in a new issue