Merge branch 'master' of github.com:keen-studio/payload

This commit is contained in:
James
2020-09-25 16:21:11 -04:00
16 changed files with 245 additions and 12 deletions

View File

@@ -0,0 +1,23 @@
/* eslint-disable no-param-reassign */
function verifyEmail(collection) {
async function resolver(_, args, context) {
if (args.locale) context.req.locale = args.locale;
if (args.fallbackLocale) context.req.fallbackLocale = args.fallbackLocale;
const options = {
collection,
token: args.token,
req: context.req,
res: context.res,
api: 'GraphQL',
};
const success = await this.operations.collections.auth.verifyEmail(options);
return success;
}
const verifyEmailResolver = resolver.bind(this);
return verifyEmailResolver;
}
module.exports = verifyEmail;

View File

@@ -35,9 +35,15 @@ async function login(args) {
if (!userDoc || (args.collection.config.auth.emailVerification && !userDoc._verified)) {
throw new AuthenticationError();
}
if (userDoc && userDoc.isLocked) {
throw new AuthenticationError();
}
const authResult = await userDoc.authenticate(password);
if (!authResult.user) {
if (authResult.user) {
await authResult.user.resetLoginAttempts();
} else {
await userDoc.incLoginAttempts();
throw new AuthenticationError();
}

View File

@@ -10,17 +10,17 @@ async function verifyEmail(args) {
// 2. Perform password reset
// /////////////////////////////////////
// TODO: How do we know which collection this is?
const user = await args.collection.Model.findOne({
_verificationToken: args.token,
});
if (!user) throw new APIError('User not found.', httpStatus.BAD_REQUEST);
if (user && user._verified === true) throw new APIError('Already activated', httpStatus.ACCEPTED);
user._verified = true;
user._verificationToken = null;
await user.save();
return true;
}
module.exports = verifyEmail;

View File

@@ -11,6 +11,7 @@ import ForgotPassword from './views/ForgotPassword';
import Login from './views/Login';
import Logout from './views/Logout';
import NotFound from './views/NotFound';
import Verify from './views/Verify';
import CreateFirstUser from './views/CreateFirstUser';
import Edit from './views/collections/Edit';
import EditGlobal from './views/Global';
@@ -77,6 +78,21 @@ const Routes = () => {
<ResetPassword />
</Route>
{collections.map((collection) => {
if (collection?.auth?.emailVerification) {
return (
<Route
key={`${collection.slug}-verify`}
path={`${match.url}/${collection.slug}/verify/:token`}
exact
>
<Verify />
</Route>
);
}
return null;
})}
<Route
render={() => {
if (user) {

View File

@@ -7,7 +7,7 @@ import Meta from '../../utilities/Meta';
const NotFound = () => {
const { setStepNav } = useStepNav();
const { routes: { admin } } = useConfig;
const { routes: { admin } } = useConfig();
useEffect(() => {
setStepNav([{

View File

@@ -0,0 +1,68 @@
import React, { useEffect, useState } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import Logo from '../../graphics/Logo';
import MinimalTemplate from '../../templates/Minimal';
import Button from '../../elements/Button';
import Meta from '../../utilities/Meta';
import { useConfig } from '../../providers/Config';
import { useAuthentication } from '../../providers/Authentication';
import Login from '../Login';
import './index.scss';
const baseClass = 'verify';
const Verify = () => {
const { user } = useAuthentication();
const { token } = useParams();
const { pathname } = useLocation();
const { serverURL, routes: { admin: adminRoute }, admin: { user: adminUser } } = useConfig();
const collectionToVerify = pathname.split('/')?.[2];
const isAdminUser = collectionToVerify === adminUser;
const [verifyResult, setVerifyResult] = useState(null);
useEffect(() => {
async function verifyToken() {
const result = await fetch(`${serverURL}/api/${collectionToVerify}/verify/${token}`, { method: 'POST' });
setVerifyResult(result);
}
verifyToken();
}, [setVerifyResult, collectionToVerify, pathname, serverURL, token]);
if (user) {
return <Login />;
}
const getText = () => {
if (verifyResult?.status === 200) return 'Verified Successfully';
if (verifyResult?.status === 202) return 'Already Activated';
return 'Unable To Verify';
};
return (
<MinimalTemplate className={baseClass}>
<Meta
title="Verify"
description="Verify user"
keywords="Verify, Payload, CMS"
/>
<div className={`${baseClass}__brand`}>
<Logo />
</div>
<h2>
{getText()}
</h2>
{isAdminUser && verifyResult?.status === 200 && (
<Button
el="link"
buttonStyle="secondary"
to={`${adminRoute}/login`}
>
Login
</Button>
)}
</MinimalTemplate>
);
};
export default Verify;

View File

@@ -0,0 +1,16 @@
@import '../../../scss/styles';
.verify {
display: flex;
align-items: center;
text-align: center;
flex-wrap: wrap;
min-height: 100vh;
&__brand {
display: flex;
justify-content: center;
width: 100%;
margin-bottom: base(2);
}
}

View File

@@ -16,7 +16,7 @@ function registerCollections() {
} = this.graphQL.resolvers.collections;
const {
login, logout, me, init, refresh, forgotPassword, resetPassword,
login, logout, me, init, refresh, forgotPassword, resetPassword, verifyEmail,
} = this.graphQL.resolvers.collections.auth;
Object.keys(this.collections).forEach((slug) => {
@@ -266,6 +266,14 @@ function registerCollections() {
resolve: resetPassword(collection),
};
this.Mutation.fields[`verifyEmail${singularLabel}`] = {
type: GraphQLBoolean,
args: {
token: { type: GraphQLString },
},
resolve: verifyEmail(collection),
};
this.Mutation.fields[`refreshToken${singularLabel}`] = {
type: new GraphQLObjectType({
name: formatName(`${slug}Refreshed${singularLabel}`),

View File

@@ -20,7 +20,46 @@ function registerCollections() {
const schema = buildSchema(formattedCollection, this.config);
if (collection.auth) {
schema.plugin(passportLocalMongoose, { usernameField: 'email' });
schema.plugin(passportLocalMongoose, {
usernameField: 'email',
});
// Check if collection is the admin user set in config
if (collection.slug === this.config.admin.user) {
schema.add({ loginAttempts: { type: Number, hide: true, default: 0 } });
schema.add({ lockUntil: { type: Date, hide: true } });
schema.virtual('isLocked').get(() => !!(this.lockUntil && this.lockUntil > Date.now()));
const { maxLoginAttempts, lockTime } = this.config.admin;
// eslint-disable-next-line func-names
schema.methods.incLoginAttempts = function (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 = { $inc: { loginAttempts: 1 } };
// Lock the account if at max attempts and not already locked
if (this.loginAttempts + 1 >= maxLoginAttempts && !this.isLocked) {
updates.$set = { lockUntil: Date.now() + lockTime };
}
return this.updateOne(updates, cb);
};
// eslint-disable-next-line func-names
schema.methods.resetLoginAttempts = function (cb) {
return this.updateOne({
$set: { loginAttempts: 0 },
$unset: { lockUntil: 1 },
}, cb);
};
}
schema.path('hash').options.hide = true;
schema.path('salt').options.hide = true;
if (collection.auth.emailVerification) {

View File

@@ -3,6 +3,7 @@ require('isomorphic-fetch');
const express = require('express');
const graphQLPlayground = require('graphql-playground-middleware-express').default;
const rateLimit = require('express-rate-limit');
const logger = require('./utilities/logger')();
const bindOperations = require('./init/bindOperations');
const bindRequestHandlers = require('./init/bindRequestHandlers');
@@ -98,8 +99,12 @@ class Payload {
},
}));
// Bind router to API
this.express.use(this.config.routes.api, this.router);
const apiLimiter = rateLimit({
windowMs: this.config.rateLimit.window,
max: this.config.rateLimit.max,
});
// Bind router to API and add rate limiter
this.express.use(this.config.routes.api, apiLimiter, this.router);
// Enable static routes for all collections permitting upload
this.initStatic();

View File

@@ -6,7 +6,7 @@ const logout = require('../auth/graphql/resolvers/logout');
const me = require('../auth/graphql/resolvers/me');
const refresh = require('../auth/graphql/resolvers/refresh');
const resetPassword = require('../auth/graphql/resolvers/resetPassword');
// const verifyEmail = require('../auth/resolvers/verifyEmail');
const verifyEmail = require('../auth/graphql/resolvers/verifyEmail');
const create = require('../collections/graphql/resolvers/create');
const find = require('../collections/graphql/resolvers/find');
@@ -37,7 +37,7 @@ function bindResolvers(ctx) {
me: me.bind(ctx),
refresh: refresh.bind(ctx),
resetPassword: resetPassword.bind(ctx),
// verifyEmail: verifyEmail.bind(ctx),
verifyEmail: verifyEmail.bind(ctx),
},
},
globals: {

View File

@@ -29,6 +29,9 @@ const sanitizeConfig = (config) => {
sanitizedConfig.collections.push(defaultUser);
}
sanitizedConfig.maxLoginAttempts = sanitizedConfig.maxLoginAttempts || 3;
sanitizedConfig.lockTime = sanitizedConfig.lockTime || 600000; // 10 minutes
sanitizedConfig.email = config.email || {};
sanitizedConfig.email.fromName = sanitizedConfig.email.fromName || 'Payload';
sanitizedConfig.email.fromAddress = sanitizedConfig.email.fromName || 'hello@payloadcms.com';
@@ -44,6 +47,10 @@ const sanitizeConfig = (config) => {
graphQLPlayground: (config.routes && config.routes.graphQLPlayground) ? config.routes.graphQLPlayground : '/graphql-playground',
};
sanitizedConfig.rateLimit = config.rateLimit || {};
sanitizedConfig.rateLimit.window = sanitizedConfig.rateLimit.window || 15 * 60 * 100; // 15min default
sanitizedConfig.rateLimit.max = sanitizedConfig.rateLimit.max || 100;
sanitizedConfig.components = { ...(config.components || {}) };
sanitizedConfig.hooks = { ...(config.hooks || {}) };
sanitizedConfig.admin = { ...(config.admin || {}) };