Implementing 2FA #2555
Replies: 5 comments 11 replies
-
2FA is why I have to consider alternative CMS options at the moment. It should be a feature that doesn't require a 3rd party plugin. Hopefully this will be part of the future roadmap. |
Beta Was this translation helpful? Give feedback.
-
I couldn't get strategies to work, so this is my hacky solution. I think it works, but please comment if I'm missing something. I can't get the after login redirect to /admin with a User collection hook to work - would welcome a solution suggestion for this. I hope MFA can be simply switched on via a config prop one day.
server.ts app.get('/admin/login', (_, res) => {
res.redirect('/admin/login-mfa')
})
app.get('/admin/login-mfa', (_, res) => {
res.set('Content-Type', 'text/html')
res.sendFile(__dirname + '/views/login-mfa.html')
}) views/login-mfa.html <!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<title>MFA Login to Payload</title>
</head>
<body>
<div class="container" style="padding-top: 100px; max-width: 400px">
<h3>Login</h3>
<br />
<form
method="post"
action="/api/users/login">
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email">
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password">
</div>
<div class="mb-3">
<label for="token" class="form-label">Token</label>
<input type="text" class="form-control" id="token" name="token">
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" class="btn btn-primary float-right">Login</button>
</div>
</form>
</div>
</body>
</html> .env
Users.ts import { nanoid } from 'nanoid'
const notp = require('notp')
const base32 = require('hi-base32')
const MFAHook: CollectionBeforeLoginHook = async ({ req, user }) => {
if (!notp.totp.verify(req.body.token, user.mfa_key, {})) {
throw new Error('Token invalid')
}
}
const Users: CollectionConfig = {
auth: {
tokenExpiration: 86400,
maxLoginAttempts: 3,
lockTime: 600 * 1000,
},
hooks: {
beforeLogin:
process.env.PAYLOAD_PUBLIC_ENABLE_MFA === '1' ? [MFAHook] : [],
},
fields: [
{
label: 'MFA Key',
name: 'mfa_key',
type: 'text',
required: true,
defaultValue: () => nanoid(20),
},
{
label: 'MFA Google Key',
name: 'google_mfa_key',
type: 'text',
hooks: {
beforeChange: [
(data) => {
return base32.encode(data.siblingData.mfa_key)
},
],
},
admin: {
readOnly: true,
description:
'Enter this key into Google Authenticator. Save to see updated value.',
},
},
... |
Beta Was this translation helpful? Give feedback.
-
I have the requirement to implement 2FA authentication as well and came up with following solution. Please note, that this solutions only works with the current payloadcms@beta (3.0.0). First, override the default login page by creating your own one (or try to reuse payload's components somehow). Thus, the login page is handled on our own: // ./src/app/(payload)/admin/login/page.tsx
'use client'
import { login } from '@/app/(payload)/admin/login/actions/login'
import { useFormState } from 'react-dom'
import { useEffect } from 'react'
import { redirect } from 'next/navigation'
import { twMerge } from 'tailwind-merge'
export default function LoginPage() {
const [state, formAction] = useFormState(login, { error: false, success: false })
useEffect(() => {
if (state.success) {
redirect('/admin')
}
}, [state.success])
return (
<div className={'flex h-full w-full flex-col items-center justify-center gap-4'}>
<h1 className={'text-2xl font-bold'}>Login</h1>
<form action={formAction} className={'flex min-w-96 flex-col gap-4'}>
<div>
<label>Username</label>
<input type={'email'} name={'email'} className={'w-full rounded border p-2'} />
</div>
<div>
<label>Username</label>
<input type={'password'} name={'password'} className={'w-full rounded border p-2'} />
</div>
<div>
<label>TOTP</label>
<input type={'text'} name={'totp'} className={'w-full rounded border p-2'} />
</div>
<button type="submit" className={'w-full rounded bg-black p-2 text-white hover:bg-black/90 active:bg-black/80'}>
Login
</button>
<span className={twMerge('invisible text-lg font-bold text-red-700', state.error && 'visible')}>
Please check your credentials
</span>
</form>
</div>
)
} Create an action that handles the login: // ./src/app/(payload)/admin/login/actions/login.ts
"use server";
import payloadConfig from '@payload-config'
import { getPayload } from 'payload'
import { cookies } from 'next/headers'
export async function login(prevState: any, formData: FormData) {
const payload = await getPayload({
config: payloadConfig,
});
try {
const user = await payload.login({
collection: 'users',
data: {
email: formData.get('email') as string,
password: formData.get('password') as string,
},
context: {
totp: formData.get('totp'),
}
});
cookies().set('payload-token', user.token!);
return {
success: true,
error: false,
}
} catch (e) {
console.error('Error while login', e);
return {
error: true,
success: false,
};
}
} Add a // src/collections/Users.ts
import type { CollectionConfig } from 'payload/types'
import { authenticator } from 'otplib'
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
hooks: {
beforeLogin: [
(args) => {
const { totp } = args.context
if (typeof totp !== 'string' || totp.trim().length !== 6) {
throw new Error('Invalid TOTP')
}
if (
!authenticator.verify({
token: totp.trim(),
secret: args.user.totp,
})
) {
throw new Error('Invalid TOTP')
}
},
],
},
fields: [
{
name: 'totp',
type: 'text',
defaultValue: () => authenticator.generateSecret(),
label: 'TOTP Secret',
admin: {
description: 'Use the secret in the authenticator app',
},
},
],
} Of course this is just a proof of concept on my side and the code is far from a final state yet, but it works. Especially setting the cookie relies on payload internals - this could of course change in the future. I hope, that the payload team considers MFA for their roadmap, since this is a requirement that I see quite often. |
Beta Was this translation helpful? Give feedback.
-
https://www.youtube.com/watch?v=Tpqt_q7KWPQ&ab_channel=Zawoj seems to work well. Thanks to @zawoj for taking the time to put the video together. Tutorial code here - https://github.com/AFEWlondon/payload-cms-2fa |
Beta Was this translation helpful? Give feedback.
-
I've did a TOTP plugin (unreleased). The only thing that prevents me from releasing is the e2e tests. |
Beta Was this translation helpful? Give feedback.
-
Hey, just want to do a sanity check and share my findings for how 2FA could be implemented.
On the API side I think it's all pretty straightforward so I'll only focus on the admin UI part of it.
Payload does support additional strategies but 2FA should function as if it's part of the core authentication so I've been exploring the ideal way for a plugin to support this.
Problem
You can't hook into the primary Login button or flow and attach additional logic to check if the user has 2FA enabled and then to prompt for the pass code.
My solution
I wanted to override the main
Login
component via setting it to the same route as the actual Login page to change the flow slightly however this is proving problematic for a few reasons:Link
also doesn't work when doing thisSo my conclusion is that a clean execution isn't very easy.
From my research as well it seems that the majority of tutorials are using extremely outdated and unmaintained packages.
My conclusion is that the efforts here might be better placed into bringing it into core functionality for authentication in Payload.
Unless somebody has a better idea, I'd love to hear it.
PS. I'd be happy to work on implementing this in core if @jmikrut or @DanRibbens can weigh in on the feasibility here.
Beta Was this translation helpful? Give feedback.
All reactions