chore: remove passport local mongoose (#2713)
This commit is contained in:
committed by
Dan Ribbens
parent
863be3d852
commit
03b1ee0896
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
41
src/auth/strategies/local/authenticate.ts
Normal file
41
src/auth/strategies/local/authenticate.ts
Normal 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
|
||||
}
|
||||
}
|
||||
39
src/auth/strategies/local/generatePasswordSaltHash.ts
Normal file
39
src/auth/strategies/local/generatePasswordSaltHash.ts
Normal 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 }
|
||||
}
|
||||
56
src/auth/strategies/local/incrementLoginAttempts.ts
Normal file
56
src/auth/strategies/local/incrementLoginAttempts.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
43
src/auth/strategies/local/register.ts
Normal file
43
src/auth/strategies/local/register.ts
Normal 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
|
||||
}
|
||||
|
||||
23
src/auth/strategies/local/resetLoginAttempts.ts
Normal file
23
src/auth/strategies/local/resetLoginAttempts.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user