chore: remove passport local mongoose (#2713)

This commit is contained in:
James Mikrut
2023-05-26 12:20:11 -04:00
committed by Dan Ribbens
parent 863be3d852
commit 03b1ee0896
16 changed files with 276 additions and 83 deletions

View File

@@ -4,7 +4,7 @@ import { extractTranslations } from '../../translations/extractTranslations';
const labels = extractTranslations(['general:email']);
export default [
const baseAuthFields: Field[] = [
{
name: 'email',
label: labels['general:email'],
@@ -27,4 +27,16 @@ export default [
type: 'date',
hidden: true,
},
] as Field[];
{
name: 'salt',
type: 'text',
hidden: true,
},
{
name: 'hash',
type: 'text',
hidden: true,
}
];
export default baseAuthFields

View File

@@ -11,6 +11,8 @@ import { User } from '../types';
import { Collection } from '../../collections/config/types';
import { afterRead } from '../../fields/hooks/afterRead';
import unlock from './unlock';
import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts';
import { authenticateLocalStrategy } from '../strategies/local/authenticate';
export type Result = {
user?: User,
@@ -77,7 +79,9 @@ async function login<TSlug extends keyof GeneratedTypes['collections']>(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Improper typing in library, additional args should be optional
const userDoc = await Model.findByUsername(email);
const userDoc = await Model.findOne({ email }).lean();
let user = JSON.parse(JSON.stringify(userDoc));
if (!userDoc || (args.collection.config.auth.verify && userDoc._verified === false)) {
throw new AuthenticationError(req.t);
@@ -87,12 +91,21 @@ async function login<TSlug extends keyof GeneratedTypes['collections']>(
throw new LockedAuth(req.t);
}
const authResult = await userDoc.authenticate(password);
const authResult = await authenticateLocalStrategy({ password, doc: user });
user = sanitizeInternalFields(user);
const maxLoginAttemptsEnabled = args.collection.config.auth.maxLoginAttempts > 0;
if (!authResult.user) {
if (maxLoginAttemptsEnabled) await userDoc.incLoginAttempts();
if (!authResult) {
if (maxLoginAttemptsEnabled) {
await incrementLoginAttempts({
payload: req.payload,
doc: user,
collection: collectionConfig,
});
}
throw new AuthenticationError(req.t);
}
@@ -108,10 +121,6 @@ async function login<TSlug extends keyof GeneratedTypes['collections']>(
});
}
let user = userDoc.toJSON({ virtuals: true });
user = JSON.parse(JSON.stringify(user));
user = sanitizeInternalFields(user);
const fieldsToSign = collectionConfig.fields.reduce((signedFields, field: Field) => {
const result = {
...signedFields,

View File

@@ -6,6 +6,8 @@ import getCookieExpiration from '../../utilities/getCookieExpiration';
import { UserDocument } from '../types';
import { fieldAffectsData } from '../../fields/config/types';
import { PayloadRequest } from '../../express/types';
import { authenticateLocalStrategy } from '../strategies/local/authenticate';
import { generatePasswordSaltHash } from '../strategies/local/generatePasswordSaltHash';
export type Result = {
token: string
@@ -49,14 +51,20 @@ async function resetPassword(args: Arguments): Promise<Result> {
// Reset Password
// /////////////////////////////////////
const user = await Model.findOne({
let user = await Model.findOne({
resetPasswordToken: data.token,
resetPasswordExpiration: { $gt: Date.now() },
}) as UserDocument;
}).lean();
user = JSON.parse(JSON.stringify(user));
if (!user) throw new APIError('Token is either invalid or has expired.');
await user.setPassword(data.password);
// TODO: replace this method
const { salt, hash } = await generatePasswordSaltHash({ password: data.password })
user.salt = salt;
user.hash = hash;
user.resetPasswordExpiration = Date.now();
@@ -64,9 +72,11 @@ async function resetPassword(args: Arguments): Promise<Result> {
user._verified = true;
}
await user.save();
let doc = await Model.findByIdAndUpdate({ _id: user.id }, user, { new: true }).lean()
await user.authenticate(data.password);
doc = JSON.parse(JSON.stringify(doc))
await authenticateLocalStrategy({ password: data.password, doc });
const fieldsToSign = collectionConfig.fields.reduce((signedFields, field) => {
if (fieldAffectsData(field) && field.saveToJWT) {

View File

@@ -2,6 +2,7 @@ import { APIError } from '../../errors';
import executeAccess from '../executeAccess';
import { Collection } from '../../collections/config/types';
import { PayloadRequest } from '../../express/types';
import { resetLoginAttempts } from '../strategies/local/resetLoginAttempts';
export type Args = {
collection: Collection
@@ -46,7 +47,11 @@ async function unlock(args: Args): Promise<boolean> {
if (!user) return null;
await user.resetLoginAttempts();
await resetLoginAttempts({
payload: req.payload,
collection: collectionConfig,
doc: user,
});
return true;
}

View File

@@ -0,0 +1,41 @@
import crypto from 'crypto'
import scmp from 'scmp'
import { TypeWithID } from "../../../collections/config/types"
import { AuthenticationError } from "../../../errors"
type Doc = TypeWithID & Record<string, unknown>
type Args = {
doc: Doc
password: string
}
export const authenticateLocalStrategy = async ({
doc,
password,
}: Args): Promise<Doc | null> => {
try {
const salt = doc.salt
const hash = doc.hash
if (typeof salt === 'string' && typeof hash === 'string') {
const res = await new Promise<Doc | null>((resolve, reject) => {
crypto.pbkdf2(password, salt, 25000, 512, 'sha256', (e, hashBuffer) => {
if (e) reject(null)
if (scmp(hashBuffer, Buffer.from(hash, 'hex'))) {
resolve(doc)
} else {
reject(null)
}
});
})
return res
}
return null
} catch (err) {
return null
}
}

View File

@@ -0,0 +1,39 @@
import crypto from 'crypto'
import { ValidationError } from "../../../errors"
const defaultPasswordValidator = (password: string): string | true => {
if (!password) return 'No password was given'
if (password.length < 3) return 'Password must be at least 3 characters'
return true
}
function randomBytes(): Promise<Buffer> {
return new Promise((resolve, reject) => crypto.randomBytes(32, (err, saltBuffer) => (err ? reject(err) : resolve(saltBuffer))));
}
function pbkdf2Promisified(password: string, salt: string): Promise<Buffer> {
return new Promise((resolve, reject) => crypto.pbkdf2(password, salt, 25000, 512, 'sha256', (err, hashRaw) => (err ? reject(err) : resolve(hashRaw))));
}
type Args = {
password: string
}
export const generatePasswordSaltHash = async ({
password,
}: Args): Promise<{ salt: string, hash: string }> => {
const validationResult = defaultPasswordValidator(password)
if (typeof validationResult === 'string') {
throw new ValidationError([{ message: validationResult, field: 'password' }])
}
const saltBuffer = await randomBytes()
const salt = saltBuffer.toString('hex')
const hashRaw = await pbkdf2Promisified(password, salt)
const hash = hashRaw.toString('hex')
return { salt, hash }
}

View File

@@ -0,0 +1,56 @@
import { Payload } from "../../.."
import { SanitizedCollectionConfig, TypeWithID } from "../../../collections/config/types"
type Args = {
payload: Payload
doc: TypeWithID & Record<string, unknown>
collection: SanitizedCollectionConfig
}
export const incrementLoginAttempts = async ({
payload,
doc,
collection,
}: Args): Promise<void> => {
const {
auth: {
maxLoginAttempts,
lockTime,
}
} = collection
if ('lockUntil' in doc && typeof doc.lockUntil === 'string') {
const lockUntil = Math.floor(new Date(doc.lockUntil).getTime() / 1000)
// Expired lock, restart count at 1
if (lockUntil < Date.now()) {
await payload.update({
collection: collection.slug,
id: doc.id,
data: {
loginAttempts: 1,
lockUntil: null,
}
})
}
return
}
const data: Record<string, unknown> = {
loginAttempts: Number(doc.loginAttempts) + 1,
}
// Lock the account if at max attempts and not already locked
if (typeof doc.loginAttempts === 'number' && doc.loginAttempts + 1 >= maxLoginAttempts) {
const lockUntil = new Date((Date.now() + lockTime))
data.lockUntil = lockUntil
}
await payload.update({
collection: collection.slug,
id: doc.id,
data,
})
}

View File

@@ -0,0 +1,43 @@
import { ValidationError } from "../../../errors"
import { Payload } from '../../..'
import { SanitizedCollectionConfig } from '../../../collections/config/types'
import { generatePasswordSaltHash } from './generatePasswordSaltHash';
type Args = {
collection: SanitizedCollectionConfig
doc: Record<string, unknown>
password: string
payload: Payload
}
export const registerLocalStrategy = async ({
collection,
doc,
password,
payload,
}: Args): Promise<Record<string, unknown>> => {
const existingUser = await payload.find({
collection: collection.slug,
depth: 0,
where: {
email: {
equals: doc.email,
}
}
})
if (existingUser.docs.length > 0) {
throw new ValidationError([{ message: 'A user with the given email is already registered', field: 'email' }])
}
const { salt, hash } = await generatePasswordSaltHash({ password })
const result = await payload.collections[collection.slug].Model.create({
...doc,
salt,
hash
})
return result
}

View File

@@ -0,0 +1,23 @@
import { Payload } from "../../.."
import { SanitizedCollectionConfig, TypeWithID } from "../../../collections/config/types"
type Args = {
payload: Payload
doc: TypeWithID & Record<string, unknown>
collection: SanitizedCollectionConfig
}
export const resetLoginAttempts = async ({
payload,
doc,
collection,
}: Args): Promise<void> => {
await payload.update({
collection: collection.slug,
id: doc.id,
data: {
loginAttempts: 0,
lockUntil: null,
},
})
}

View File

@@ -1,6 +1,5 @@
import mongoose, { UpdateAggregationStage, UpdateQuery } from 'mongoose';
import mongoose from 'mongoose';
import paginate from 'mongoose-paginate-v2';
import passportLocalMongoose from 'passport-local-mongoose';
import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2';
import { buildVersionCollectionFields } from '../versions/buildCollectionFields';
import getBuildQueryPlugin from '../mongoose/buildQuery';
@@ -16,52 +15,6 @@ export default function initCollectionsLocal(ctx: Payload): void {
const schema = buildCollectionSchema(formattedCollection, ctx.config);
if (collection.auth && !collection.auth.disableLocalStrategy) {
schema.plugin(passportLocalMongoose, {
usernameField: 'email',
errorMessages: {
UserExistsError: 'A user with the given email is already registered',
},
});
const { maxLoginAttempts, lockTime } = collection.auth;
if (maxLoginAttempts > 0) {
type LoginSchema = {
loginAttempts: number
lockUntil: number
isLocked: boolean
};
// eslint-disable-next-line func-names
schema.methods.incLoginAttempts = function (this: mongoose.Document & LoginSchema, cb) {
// Expired lock, restart count at 1
if (this.lockUntil && this.lockUntil < Date.now()) {
return this.updateOne({
$set: { loginAttempts: 1 },
$unset: { lockUntil: 1 },
}, cb);
}
const updates: UpdateQuery<LoginSchema> = { $inc: { loginAttempts: 1 } };
// Lock the account if at max attempts and not already locked
if (this.loginAttempts + 1 >= maxLoginAttempts) {
updates.$set = { lockUntil: Date.now() + lockTime };
}
return this.updateOne(updates as UpdateAggregationStage, cb);
};
// eslint-disable-next-line func-names
schema.methods.resetLoginAttempts = function (cb) {
return this.updateOne({
$set: { loginAttempts: 0 },
$unset: { lockUntil: 1 },
}, cb);
};
}
}
if (collection.versions) {
const versionModelName = getVersionsModelName(collection);

View File

@@ -22,6 +22,7 @@ import { afterRead } from '../../fields/hooks/afterRead';
import { generateFileData } from '../../uploads/generateFileData';
import { saveVersion } from '../../versions/saveVersion';
import { mapAsync } from '../../utilities/mapAsync';
import { registerLocalStrategy } from '../../auth/strategies/local/register';
const unlinkFile = promisify(fs.unlink);
@@ -197,15 +198,12 @@ async function create<TSlug extends keyof GeneratedTypes['collections']>(
resultWithLocales._verificationToken = crypto.randomBytes(20).toString('hex');
}
try {
doc = await Model.register(resultWithLocales, data.password as string);
} catch (error) {
// Handle user already exists from passport-local-mongoose
if (error.name === 'UserExistsError') {
throw new ValidationError([{ message: error.message, field: 'email' }], req.t);
}
throw error;
}
doc = await registerLocalStrategy({
collection: collectionConfig,
doc: resultWithLocales,
payload: req.payload,
password: data.password as string,
})
} else {
try {
doc = await Model.create(resultWithLocales);

View File

@@ -18,6 +18,7 @@ import { generateFileData } from '../../uploads/generateFileData';
import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion';
import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles';
import { unlinkTempFiles } from '../../uploads/unlinkTempFiles';
import { generatePasswordSaltHash } from '../../auth/strategies/local/generatePasswordSaltHash';
export type Arguments<T extends { [field: string | number | symbol]: unknown }> = {
collection: Collection
@@ -223,9 +224,12 @@ async function updateByID<TSlug extends keyof GeneratedTypes['collections']>(
// Handle potential password update
// /////////////////////////////////////
if (shouldSavePassword) {
await doc.setPassword(password);
await doc.save();
const dataToUpdate: Record<string, unknown> = { ...result }
if (shouldSavePassword && typeof password === 'string') {
const { hash, salt } = await generatePasswordSaltHash({ password })
dataToUpdate.salt = salt
dataToUpdate.hash = hash
delete data.password;
delete result.password;
}
@@ -238,7 +242,7 @@ async function updateByID<TSlug extends keyof GeneratedTypes['collections']>(
try {
result = await Model.findByIdAndUpdate(
{ _id: id },
result,
dataToUpdate,
{ new: true },
);
} catch (error) {