apostrophe
Version:
The Apostrophe Content Management System.
1,218 lines (1,123 loc) • 39.9 kB
JavaScript
// Enable users to log in via a login form on the site at `/login`.
//
// ## Options
//
// `localLogin`
//
// If explicitly set to `false`, the `/login` route does not exist,
// and it is not possible to log in via your username and password.
// This usually makes sense only in the presence of an alternative such as
// the `@apostrophecms/passport` module, which adds support for login via
// Google, Twitter, gitlab, etc.
//
// `passwordReset`
//
// If set to `true`, the user is given the option to reset their password,
// provided they can receive a confirmation email.
// Not available if `localLogin` is `false`.
//
// `passwordResetHours`
//
// When `passwordReset` is `true`, this option controls how many hours
// a password reset request remains valid. If the confirmation email is not
// acted upon in time, the user must request a password reset again.
// The default is `48`.
//
// `bearerTokens`
//
// If not explicitly set to `false`, apps may log in via the login route
// and receive a `token` as a response, which can then be presented as a
// bearer token. If set to `false`, only session-based logins are possible.
//
// May be set to an object with a `lifetime` property, in milliseconds.
// The default bearer token lifetime is 2 weeks.
//
// ## Notable properties of apos.modules['@apostrophecms/login']
//
// `passport`
//
// Apostrophe's instance of the [passport](https://npmjs.org/package/passport) npm module.
// You may access this object if you need to implement additional passport
// "strategies."
const Passport = require('passport').Passport;
const LocalStrategy = require('passport-local');
const Promise = require('bluebird');
const { createId } = require('@paralleldrive/cuid2');
const expressSession = require('express-session');
const loginAttemptsNamespace = '@apostrophecms/loginAttempt';
const loggedInCookieName = 'loggedIn';
module.exports = {
cascades: [ 'requirements' ],
options: {
alias: 'login',
placeholder: {
username: 'apostrophe:enterUsername',
password: 'apostrophe:enterPassword'
},
caseInsensitive: false,
localLogin: true,
passwordReset: false,
passwordResetHours: 48,
// Maximum time to complete login (1 hour)
incompleteLifetime: 60 * 60 * 1000,
scene: 'apos',
csrfExceptions: [
'login'
],
bearerTokens: true,
throttle: {
allowedAttempts: 3,
perMinutes: 1,
lockoutMinutes: 1
},
minimumWhoamiFields: [ '_id', 'username', 'title', 'email' ],
whoamiFields: []
},
async init(self) {
self.passport = new Passport();
self.enableSerializeUsers();
self.enableDeserializeUsers();
if (self.options.localLogin !== false) {
self.enableLocalStrategy();
}
self.enableBrowserData();
await self.enableBearerTokens();
self.addToAdminBar();
self.addCaseInsensitiveMigration();
self.cleanupInterval = setInterval(self.cleanup, self.options.incompleteLifetime);
},
handlers(self) {
return {
'apostrophe:modulesRegistered': {
addSecret() {
// So this property is hashed and the hash kept in the safe,
// rather than ever being stored literally
self.apos.user.addSecret('passwordReset');
},
async checkForUser() {
await self.checkForUserAndAlert();
}
},
'apostrophe:destroy': {
clearIntervals() {
if (self.cleanupInterval) {
clearInterval(self.cleanupInterval);
self.cleanupInterval = null;
}
}
}
};
},
routes(self) {
if (!self.options.localLogin) {
return {};
}
return {
get: {
// Login page
[self.login()]: async (req, res) => {
if (req.user) {
return res.redirect('/');
}
req.scene = 'apos';
try {
await self.sendPage(req, 'login', {});
} catch (e) {
self.apos.util.error(e);
return res.status(500).send('error');
}
}
}
};
},
apiRoutes(self) {
const routes = {
post: {
async logout(req) {
if (!req.user) {
throw self.apos.error('forbidden', req.t('apostrophe:logOutNotLoggedIn'));
}
if (req.token) {
await self.bearerTokens.removeOne({
userId: req.user._id,
_id: req.token
});
}
if (req.session) {
const destroySession = () => {
return require('util').promisify(function (callback) {
// Be thorough, nothing in the session potentially
// related to the login should survive logout
return req.session.destroy(callback);
})();
};
const cookie = req.session.cookie;
await destroySession();
// Session cookie expiration isn't automatic with
// `req.session.destroy`. Fix that to reduce challenges for those
// attempting to implement custom caching strategies at the edge https://github.com/expressjs/session/issues/241
const expireCookie = new expressSession.Cookie(cookie);
expireCookie.expires = new Date(0);
const name = self.apos.modules['@apostrophecms/express'].sessionOptions.name;
req.res.header('set-cookie', expireCookie.serialize(name, 'deleted'));
// TODO: get cookie name from config
req.res.cookie(`${self.apos.shortName}.${loggedInCookieName}`, 'false');
}
},
async whoami(req) {
return self.getWhoami(req);
}
},
get: {
// For bc this route is still available via GET, however
// it should be accessed via POST because the result
// may differ by individual user session and should not
// be cached
async whoami(req) {
return self.getWhoami(req);
}
}
};
if (!self.options.localLogin) {
return routes;
}
return {
post: {
...routes.post,
async login(req) {
// Don't make verify functions worry about whether this object
// is present, just the value of their own sub-property
req.body.requirements = req.body.requirements || {};
if (req.body.incompleteToken) {
return self.finalizeIncompleteLogin(req);
} else {
return self.initialLogin(req);
}
},
// invokes the `props(req, user)` function for the requirement
// specified by `body.name`. Invoked before displaying each
// `afterPasswordVerified` requirement. The return value of the
// function, which should be an object, is delivered as the API response
async requirementProps(req) {
const { user } = await self.findIncompleteTokenAndUser(
req,
req.body.incompleteToken
);
const name = self.apos.launder.string(req.body.name);
const requirement = self.requirements[name];
if (!requirement) {
throw self.apos.error('notfound');
}
if (!requirement.props) {
return {};
}
return requirement.props(req, user);
},
async requirementVerify(req) {
const name = self.apos.launder.string(req.body.name);
const loginNamespace = `${loginAttemptsNamespace}/${name}`;
const { user } = await self.findIncompleteTokenAndUser(
req,
req.body.incompleteToken
);
if (!user) {
throw self.apos.error('invalid');
}
const requirement = self.requirements[name];
if (!requirement) {
throw self.apos.error('notfound');
}
if (!requirement.verify) {
throw self.apos.error('invalid', 'You must provide a verify method in your requirement');
}
const { cachedAttempts, reached } = await self
.checkLoginAttempts(user.username, loginNamespace);
if (reached) {
throw self.apos.error('invalid', req.t('apostrophe:loginMaxAttemptsReached', {
count: self.options.throttle.lockoutMinutes
}));
}
try {
await requirement.verify(req, req.body.value, user);
const token = await self.bearerTokens.findOne({
_id: self.apos.launder.string(req.body.incompleteToken),
requirementsToVerify: { $exists: true },
expires: {
$gte: new Date()
}
});
if (!token) {
throw self.apos.error('notfound');
}
await self.bearerTokens.updateOne(token, {
$pull: { requirementsToVerify: name }
});
await self.clearLoginAttempts(user.username, loginNamespace);
return {};
} catch (err) {
await self.addLoginAttempt(
user.username,
cachedAttempts,
loginNamespace
);
err.data = err.data || {};
err.data.requirement = name;
throw err;
}
},
async context(req) {
return self.getContext(req);
},
...self.isPasswordResetEnabled() && {
async resetRequest(req) {
const wait = (t = 2000) => Promise.delay(t);
const site = (req.headers.host || '').replace(/:\d+$/, '');
const email = self.apos.launder.string(req.body.email);
if (!email.length) {
throw self.apos.error('invalid', req.t('apostrophe:loginResetEmailRequired'));
}
let user;
// error not reported to browser for security reasons
try {
user = await self.getPasswordResetUser(req.body.email);
} catch (e) {
self.apos.util.error(e);
}
if (!user) {
await wait();
self.apos.util.error(
`Reset password request error - the user ${email} doesn\`t exist.`
);
return;
}
if (!user.email) {
await wait();
self.apos.util.error(
`Reset password request error - the user ${user.username} doesn\`t have an email.`
);
return;
}
const reset = self.apos.util.generateId();
user.passwordReset = reset;
user.passwordResetAt = new Date();
await self.apos.user.update(req, user, { permissions: false });
// Fix - missing host in the absoluteUrl results in a panic.
let port = (req.headers.host || '').split(':')[1];
if (!port || [ '80', '443' ].includes(port)) {
port = '';
} else {
port = `:${port}`;
}
const parsed = new URL(
req.absoluteUrl,
self.apos.baseUrl
? undefined
: `${req.protocol}://${req.hostname}${port}`
);
parsed.pathname = self.login();
parsed.search = '?';
parsed.searchParams.append('reset', reset);
parsed.searchParams.append('email', user.email);
try {
await self.email(req, 'passwordResetEmail', {
user,
url: parsed.toString(),
site
}, {
to: user.email,
subject: req.t('apostrophe:passwordResetRequest', { site })
});
} catch (err) {
self.apos.util.error(`Error while sending email to ${user.email}`, err);
}
},
async reset(req) {
const password = self.apos.launder.string(req.body.password);
if (!password.length) {
throw self.apos.error('invalid', req.t('apostrophe:loginResetPasswordRequired'));
}
let user;
try {
user = await self.getPasswordResetUser(
req.body.email,
// important, empty to string to avoid security problems
req.body.reset || ''
);
} catch (e) {
self.apos.util.error(e);
throw self.apos.error('invalid', req.t('apostrophe:loginResetInvalid'));
}
user.passwordReset = null;
user.passwordResetAt = new Date(0);
user.password = password;
await self.apos.user.update(req, user, { permissions: false });
}
}
},
get: {
...routes.get,
// For bc this route is still available via GET, however
// it should be accessed via POST because the result
// may differ by individual user session and should not
// be cached
async context(req) {
return self.getContext(req);
},
async reset(req) {
try {
await self.getPasswordResetUser(
req.query.email,
// important, empty to string to avoid security problems
req.query.reset || ''
);
} catch (e) {
self.apos.util.error(e);
throw self.apos.error('invalid', req.t('apostrophe:loginResetInvalid'));
}
}
}
};
},
tasks(self, options) {
return {
'case-insensitive': {
usage: 'Migrate all users with case insensitive username and email',
task: self.caseInsensitiveTask
}
};
},
methods(self) {
return {
// Implements the context route, which provides basic
// information about the site being logged into and also
// props for beforeSubmit requirements
async getContext(req) {
const aposPackage = require('../../../package.json');
// For performance beforeSubmit requirement props all happen together
// here
const requirementProps = {};
for (const [ name, requirement ] of Object.entries(self.requirements)) {
if ((requirement.phase !== 'afterPasswordVerified') && requirement.props) {
try {
requirementProps[name] = await requirement.props(req);
} catch (e) {
if (e.body && e.body.data) {
e.body.data.requirement = name;
}
throw e;
}
}
}
return {
env: process.env.APOS_ENV_LABEL || self.options.environmentLabel || process.env.NODE_ENV || 'development',
name: (process.env.npm_package_name && process.env.npm_package_name.replace(/-/g, ' ')) || 'Apostrophe',
version: aposPackage.version || '3',
requirementProps
};
},
// Implements the whoami route, which provides
// information about the user that is currently
// logged in
async getWhoami(req) {
if (!req.user) {
throw self.apos.error('notfound');
}
const fields = new Set([
...self.options.minimumWhoamiFields,
...self.options.whoamiFields
]);
const user = {};
for (const field of fields) {
if (req.user[field] !== undefined) {
user[field] = req.user[field];
}
}
return user;
},
// return the loginUrl option
login(url) {
return self.options.loginUrl ? self.options.loginUrl : '/login';
},
// Set the `serializeUser` method of `passport` to serialize the
// user by storing their user ID in the session.
enableSerializeUsers() {
self.passport.serializeUser(function (user, done) {
done(null, user._id);
});
},
// Set the `deserializeUser` method of `passport`,
// wrapping the `deserializeUser` method of this
// module for use with passport's API.
// See `deserializeUser`.
enableDeserializeUsers() {
self.passport.deserializeUser(function (id, cb) {
self.deserializeUser(id).then(function (user) {
if (user) {
user._viaSession = true;
}
return cb(null, user);
}).catch(cb);
});
},
// Given a user's `_id`, fetches that user via the login module
// and, if the user is found, emits the `deserialize` event.
// If no user is found, `null` is returned, otherwise the
// user is returned.
//
// This method is passed to `passport.deserializeUser`,
// wrapped to support its async implementation.
// It is also useful when you wish to load a user exactly
// as Passport would.
async deserializeUser(id) {
const req = self.apos.task.getReq();
const user = await self.apos.user.find(req, {
_id: id,
disabled: {
$ne: true
}
}).toObject();
if (!user) {
return null;
}
await self.emit('deserialize', user);
return user;
},
// Adds the "local strategy" (username/email and password login)
// to Passport. Users are found via the `find` method of the
// [@apostrophecms/user](../@apostrophecms/user/index.html) module.
// Users with the `disabled` property set to true may not log in.
// Passwords are verified via the `verifyPassword` method of
// [@apostrophecms/user](../@apostrophecms/user/index.html), which is
// powered by the [credentials](https://npmjs.org/package/credentials) module.
enableLocalStrategy() {
self.passport.use(new LocalStrategy(self.localStrategy));
},
// Local Strategy wrapper for self.verifyLogin to work nicely with
// passport.
async localStrategy(username, password, done) {
try {
const user = await self.verifyLogin(username, password);
return done(null, user);
} catch (err) {
return done(err);
}
},
// Verify a login attempt. `username` can be either
// the username or the email address (both are unique).
//
// If the user's credentials are invalid, `false` is returned after a
// 1000ms delay to discourage abuse. If another type of error occurs, it
// is thrown normally.
//
// If the user's login SUCCEEDS, the return value is
// the `user` object.
// `attempts`, `ip` and `requestId` are optional, sent for only logging
// needs. They won't be available with passport.
async verifyLogin(username, password, attempts = 0, ip, requestId) {
const req = self.apos.task.getReq();
const loginName = self.normalizeLoginName(username);
const user = await self.apos.user.find(req, {
$or: [
{ username: loginName },
{ email: loginName }
],
disabled: { $ne: true }
}).toObject();
if (!user) {
self.logInfo('incorrect-username', {
username,
ip,
attempts: attempts + 1,
requestId
});
await Promise.delay(1000);
return false;
}
try {
await self.apos.user.verifyPassword(user, password);
self.logInfo('correct-password', {
username,
ip,
attempts,
requestId
});
return user;
} catch (err) {
if (err.name === 'invalid') {
self.logInfo('incorrect-password', {
username,
ip,
attempts: attempts + 1,
requestId
});
await Promise.delay(1000);
return false;
} else {
// Actual system error
throw err;
}
}
},
getPasswordResetLifetimeInMilliseconds() {
return 1000 * 60 * 60 * (self.options.passwordResetHours || 48);
},
isPasswordResetEnabled() {
return self.options.localLogin && self.options.passwordReset;
},
getBrowserData(req) {
return {
schema: self.getSchema(),
action: self.action,
passwordResetEnabled: self.isPasswordResetEnabled(),
...(req.user
? {
user: {
_id: req.user._id,
title: req.user.title,
username: req.user.username,
email: req.user.email
}
}
: {}),
requirements: Object.fromEntries(
Object.entries(self.requirements).map(([ name, requirement ]) => {
const browserRequirement = {
phase: requirement.phase,
propsRequired: !!requirement.props,
askForConfirmation: requirement.askForConfirmation || false
};
return [ name, browserRequirement ];
})
)
};
},
// Get a user by EITHER:
// - username/email
// - username/email AND reset token
// `resetToken` can be `false` or `string`. Passing any other type
// will be converted to string and used for searching the user.
// Sould we normalize here too?
async getPasswordResetUser(usernameOrEmail, resetToken = false) {
if (!self.isPasswordResetEnabled()) {
return null;
}
const reset = self.apos.launder.string(resetToken);
const email = self.apos.launder.string(usernameOrEmail);
if (!email.length) {
throw self.apos.error('invalid');
}
if (resetToken !== false && !reset.length) {
throw self.apos.error('invalid');
}
const adminReq = self.apos.task.getReq();
const criteriaOr = [
{ username: email },
{ email }
];
const criteriaAnd = {};
if (resetToken !== false) {
criteriaAnd.passwordResetAt = {
$gte: new Date(Date.now() - self.getPasswordResetLifetimeInMilliseconds())
};
}
const user = await self.apos.user
.find(adminReq, {
$or: criteriaOr,
...criteriaAnd
})
.toObject();
if (!user) {
throw self.apos.error('notfound');
}
if (resetToken !== false) {
await self.apos.user.verifySecret(
user,
'passwordReset',
reset
);
}
return user;
},
async checkForUserAndAlert() {
const adminReq = self.apos.task.getReq();
const user = await self.apos.user
.find(adminReq, {})
.relationships(false)
.limit(1)
.toObject();
if (!user && !self.apos.options.test) {
self.apos.util.warnDev('There are no users created for this installation of ApostropheCMS yet.');
}
},
async enableBearerTokens() {
self.bearerTokens = self.apos.db.collection('aposBearerTokens');
await self.bearerTokens.createIndex({ expires: 1 }, { expireAfterSeconds: 0 });
},
// Finalize an incomplete login based on the provided incompleteToken
// and various `requirements` subproperties.
// Implementation detail of the login route
async finalizeIncompleteLogin(req) {
const session = self.apos.launder.boolean(req.body.session);
// Completing a previous incomplete login
// (password was verified but post-password-verification
// requirements were not supplied)
const token = await self.bearerTokens.findOne({
_id: self.apos.launder.string(req.body.incompleteToken),
requirementsToVerify: {
$exists: true
},
expires: {
$gte: new Date()
}
});
if (!token) {
throw self.apos.error('notfound');
}
if (token.requirementsToVerify.length) {
throw self.apos.error('forbidden', 'All requirements must be verified');
}
const user = await self.deserializeUser(token.userId);
if (!user) {
await removeToken();
throw self.apos.error('notfound');
}
if (session) {
await removeToken();
await self.passportLogin(req, user);
// No access to login attempts in the final phase.
self.logInfo(req, 'complete', {
username: user.username
});
} else {
delete token.requirementsToVerify;
await self.bearerTokens.updateOne(token, {
$unset: {
requirementsToVerify: 1
},
$set: {
expires: Date.now() + self.getBearerTokenLifetime()
}
});
self.logInfo(req, 'complete', {
username: user.username
});
return {
token
};
}
async function removeToken() {
await self.bearerTokens.removeOne({
_id: token._id
});
}
},
// Implementation detail of the login route and the requirementProps
// mechanism for custom login requirements. Given the string `token`,
// returns `{ token, user }`. Throws an exception if the token is not
// found. `token` is sanitized before passing to mongodb.
async findIncompleteTokenAndUser(req, token) {
token = await self.bearerTokens.findOne({
_id: self.apos.launder.string(token),
requirementsToVerify: {
$exists: true,
$not: {
$size: 0
}
},
expires: {
$gte: new Date()
}
});
if (!token) {
throw self.apos.error('notfound');
}
const user = await self.deserializeUser(token.userId);
if (!user) {
await self.bearerTokens.removeOne({
_id: token._id
});
throw self.apos.error('notfound');
}
return {
token,
user
};
},
async verifyRequirements(req, requirements) {
for (const [ name, requirement ] of Object.entries(requirements)) {
try {
await requirement.verify(
req,
req.body.requirements && req.body.requirements[name]
);
} catch (e) {
e.data = e.data || {};
e.data.requirement = name;
throw e;
}
}
},
// Implementation detail of the login route. Log in the user, or if there
// are `requirements` that require password verification occur first,
// return an incomplete token.
async initialLogin(req) {
const username = self.normalizeLoginName(
self.apos.launder.string(req.body.username)
);
const password = self.apos.launder.string(req.body.password);
if (!username || !password) {
throw self.apos.error('invalid', req.t('apostrophe:loginPageBothRequired'));
}
const { cachedAttempts, reached } = await self.checkLoginAttempts(username);
const logAttempts = cachedAttempts ?? 0;
if (reached) {
throw self.apos.error('invalid', req.t('apostrophe:loginMaxAttemptsReached', {
count: self.options.throttle.lockoutMinutes
}));
}
try {
// Initial login step
const {
earlyRequirements,
onTimeRequirements,
lateRequirements
} = self.filterRequirements();
await self.verifyRequirements(req, earlyRequirements);
await self.verifyRequirements(req, onTimeRequirements);
// send log information
const user = await self.verifyLogin(
username,
password,
logAttempts,
self.apos.structuredLog.getIp(req),
self.apos.structuredLog.getRequestId(req)
);
if (!user) {
// For security reasons we may not tell the user which case applies
throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
}
const requirementsToVerify = Object.keys(lateRequirements);
if (requirementsToVerify.length) {
const token = createId();
await self.bearerTokens.insertOne({
_id: token,
userId: user._id,
requirementsToVerify,
// Default lifetime of 1 hour is generous to permit situations
// like installing a TOTP app for the first time
expires: new Date(
Date.now() + self.options.incompleteLifetime
)
});
await self.clearLoginAttempts(user.username);
return {
incompleteToken: token
};
}
const session = self.apos.launder.boolean(req.body.session);
if (session) {
await self.passportLogin(req, user);
await self.clearLoginAttempts(user.username);
self.logInfo(req, 'complete', {
username,
attempts: logAttempts
});
return {};
} else {
const token = createId();
await self.bearerTokens.insertOne({
_id: token,
userId: user._id,
expires: new Date(
Date.now() +
self.getBearerTokenLifetime()
)
});
await self.clearLoginAttempts(user.username);
self.logInfo(req, 'complete', {
username,
attempts: logAttempts
});
return {
token
};
}
} catch (err) {
await self.addLoginAttempt(username, cachedAttempts);
throw err;
}
},
filterRequirements() {
const requirements = Object.entries(self.requirements);
return {
earlyRequirements: Object.fromEntries(requirements.filter(([ , requirement ]) => requirement.phase === 'beforeSubmit')),
onTimeRequirements: Object.fromEntries(requirements.filter(([ , requirement ]) => requirement.phase === 'uponSubmit')),
lateRequirements: Object.fromEntries(requirements.filter(([ , requirement ]) => requirement.phase === 'afterPasswordVerified'))
};
},
// Awaitable wrapper for req.login. An implementation detail of the login
// route
async passportLogin(req, user) {
const cookieName = `${self.apos.shortName}.${loggedInCookieName}`;
if (req.cookies[cookieName] !== 'true') {
req.res.cookie(cookieName, 'true');
}
const passportLogin = (user) => {
return require('util').promisify(function (user, callback) {
return req.login(user, callback);
})(user);
};
await passportLogin(user);
},
async addLoginAttempt(
username,
attempts,
namespace = loginAttemptsNamespace
) {
if (typeof attempts !== 'number') {
await self.apos.cache.set(namespace,
username,
1,
self.options.throttle.perMinutes * 60
);
} else {
await self.apos.cache.cacheCollection.updateOne(
{
namespace,
key: username
},
{
$inc: {
value: 1
}
}
);
}
},
async checkLoginAttempts(username, namespace = loginAttemptsNamespace) {
const cachedAttempts = await self.apos.cache.get(namespace, username);
const { allowedAttempts } = self.options.throttle;
if (!cachedAttempts || cachedAttempts < allowedAttempts) {
return { cachedAttempts };
}
// When this is the first time we reach the limit
// we set the lifetime only once with lockoutMinutes
if (cachedAttempts === allowedAttempts) {
await self.apos.cache.set(namespace,
username,
cachedAttempts + 1,
self.options.throttle.lockoutMinutes * 60
);
}
return {
cachedAttempts,
reached: true
};
},
async clearLoginAttempts(username, namespace = loginAttemptsNamespace) {
await self.apos.cache.delete(namespace, username);
},
addToAdminBar() {
self.apos.adminBar.add(
`${self.__meta.name}-logout`,
'apostrophe:logOut',
false,
{
user: true,
last: true
}
);
},
getSchema() {
return self.apos.user.schema
.filter(({ name }) => [ 'username', 'password' ].includes(name))
.map(field => ({
name: field.name,
label: field.label,
placeholder: self.options.placeholder[field.name],
type: field.type,
required: true
})
);
},
normalizeLoginName(usernameOrEmail, {
caseInsensitive = self.options.caseInsensitive
} = {}) {
if (typeof usernameOrEmail !== 'string' || !caseInsensitive) {
return usernameOrEmail;
}
return usernameOrEmail.toLowerCase();
},
async addCaseInsensitiveMigration() {
if (self.options.caseInsensitive) {
self.apos.migration.add('login-case-insensitive', self.caseInsensitiveTask);
}
},
async caseInsensitiveTask() {
const duplicatedUsernames = [];
await self.apos.migration.eachDoc({ type: '@apostrophecms/user' }, 1, async (user) => {
const normalizedUsername = self.apos.login
.normalizeLoginName(user.username, { caseInsensitive: true });
const normalizedEmail = self.apos.login
.normalizeLoginName(user.email, { caseInsensitive: true });
const shouldUpdateUsername = user.username !== normalizedUsername;
const shouldUpdateEmail = user.email && user.email !== normalizedEmail;
if (!shouldUpdateUsername && !shouldUpdateEmail) {
return;
}
const criteria = {
$set: {
...shouldUpdateUsername && { username: normalizedUsername },
...shouldUpdateEmail && { email: normalizedEmail }
}
};
try {
await self.apos.user.safe.updateOne({ _id: user._id }, criteria);
await self.apos.doc.db.updateOne({ _id: user._id }, criteria);
} catch (err) {
if (self.apos.doc.isUniqueError(err)) {
duplicatedUsernames.push({
user: {
_id: user._id,
username: user.username,
email: user.email
},
conflictingFields: err.keyValue
});
return;
}
throw err;
}
});
if (duplicatedUsernames.length) {
self.logError(
'conflicting-usernames',
'Accounts with certain usernames and/or emails would be in conflict with other accounts if changed to lowercase. Please review the following usernames and emails and address them manually.',
{ failed: duplicatedUsernames }
);
}
},
getBearerTokenLifetime() {
return (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000;
},
// Periodic cleanup. Expired bearer tokens are only honored until they expire, but
// it also makes sense to free the resources after expiration
async cleanup() {
return self.bearerTokens.deleteMany(
{
expires: {
$lt: new Date()
}
}
);
}
};
},
middleware(self) {
return {
passportInitialize: {
before: '@apostrophecms/i18n',
middleware: self.passport.initialize()
},
passportExtendLogin: {
before: '@apostrophecms/i18n',
middleware(req, res, next) {
const superLogin = req.login.bind(req);
req.login = (user, ...args) => {
let options, callback;
// Support inconsistent calling conventions inside passport core
if (typeof args[0] === 'function') {
options = {};
callback = args[0];
} else {
options = args[0];
callback = args[1];
}
return superLogin(user, options, async (err) => {
if (err) {
return callback(err);
}
try {
await self.emit('afterSessionLogin', req);
} catch (e) {
return callback(e);
}
// Make sure no handler removed req.user
if (req.user) {
// Mark the login timestamp. Middleware takes care of ensuring
// that logins cannot be used to carry out actions prior
// to this property being added
req.session.loginAt = Date.now();
}
return callback(null);
});
};
// Passport itself maintains this bc alias, while refusing
// to actually decide which one is best in its own dev docs.
// Both have to exist to avoid bugs when passport calls itself
req.logIn = req.login;
return next();
}
},
passportSession: {
before: '@apostrophecms/i18n',
middleware: (() => {
// Wrap the passport middleware so that if the apikey or bearer token
// middleware already supplied req.user, that wins (explicit wins
// over implicit)
const passportSession = self.passport.session();
return (req, res, next) => req.user ? next() : passportSession(req, res, next);
})()
},
removeUserForDraftSharing: {
before: '@apostrophecms/i18n',
middleware(req, res, next) {
// Remove user to hide the admin UI, in order to simulate a
// logged-out page view
if (self.isShareDraftRequest(req)) {
delete req.user;
}
return next();
}
},
honorLoginInvalidBefore: {
before: '@apostrophecms/i18n',
middleware(req, res, next) {
if (
req.user &&
req.user._viaSession &&
req.user.loginInvalidBefore &&
(!req.session.loginAt || (req.session.loginAt < req.user.loginInvalidBefore))
) {
req.session.destroy();
delete req.user;
}
return next();
}
},
addUserToData: {
before: '@apostrophecms/i18n',
middleware(req, res, next) {
// Add the `user` property to `req.data` when a user is logged in.
if (req.user) {
req.data.user = req.user;
return next();
} else {
return next();
}
}
},
addLoggedInCookie: {
before: '@apostrophecms/i18n',
middleware(req, res, next) {
// TODO: get cookie name from config
const cookieName = `${self.apos.shortName}.${loggedInCookieName}`;
if (req.user && req.cookies[cookieName] !== 'true') {
res.cookie(cookieName, 'true');
}
return next();
}
}
};
}
};