From be7c70260207bfbc448b7579e4223a06ee686b8b Mon Sep 17 00:00:00 2001 From: soxa <6390896+Soxasora@users.noreply.github.com> Date: Tue, 4 Feb 2025 01:41:01 +0100 Subject: [PATCH] Login with magic code (#1818) * fix: cannot login with email on PWA * adjust other email templates * restore manual url on new user email * no padding on button section * cleanup * generate 6-digit bechh32 token * token needs to be fed as lower case; validator case insensitive * delete token if user has failed 3 times * proposal: context-independent error page * include expiration time on email page message * add expiration time to emails * independent checkPWA function * restore token deletion if successful auth * final cleanup: remove unused function * compact useVerificationToken * email.js: magic code for non-PWA users * adjust email templates * MultiInput component; magic code via MultiInput * hotfix: revert length testing; larger width for inputs * manual bech32 token generation; no upperCase * reverting to string concatenation * layout tweaks, fix error placement * pastable inputs * small nit fixes * less ambiguous error path --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b --- components/form.js | 117 +++++++++++++++- components/login.js | 3 +- lib/constants.js | 2 + lib/validate.js | 4 + pages/api/auth/[...nextauth].js | 125 ++++++++++++------ pages/auth/error.js | 12 +- pages/email.js | 51 ++++++- pages/settings/index.js | 3 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + 10 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 prisma/migrations/20250116091054_auth_token_attempts/migration.sql diff --git a/components/form.js b/components/form.js index b34af25f..5b5b12d2 100644 --- a/components/form.js +++ b/components/form.js @@ -486,7 +486,7 @@ function FormGroup ({ className, label, children }) { function InputInner ({ prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue, - innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, + innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError, ...props }) { const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props) @@ -574,7 +574,7 @@ function InputInner ({ onKeyDown={onKeyDownInner} onChange={onChangeInner} onBlur={onBlurInner} - isInvalid={invalid} + isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error} /> {(isClient && clear && field.value && !props.readOnly) && @@ -1241,5 +1241,118 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini ) } +export function MultiInput ({ + name, label, groupClassName, length = 4, charLength = 1, upperCase, showSequence, + onChange, autoFocus, hideError, inputType = 'text', + ...props +}) { + const [inputs, setInputs] = useState(new Array(length).fill('')) + const inputRefs = useRef(new Array(length).fill(null)) + const [, meta, helpers] = useField({ name }) + + useEffect(() => { + autoFocus && inputRefs.current[0].focus() // focus the first input if autoFocus is true + }, [autoFocus]) + + const updateInputs = useCallback((newInputs) => { + setInputs(newInputs) + const combinedValue = newInputs.join('') // join the inputs to get the value + helpers.setValue(combinedValue) // set the value to the formik field + onChange?.(combinedValue) + }, [onChange, helpers]) + + const handleChange = useCallback((formik, e, index) => { // formik is not used but it's required to get the value + const value = e.target.value.slice(-charLength) + const processedValue = upperCase ? value.toUpperCase() : value // convert the input to uppercase if upperCase is tru + + const newInputs = [...inputs] + newInputs[index] = processedValue + updateInputs(newInputs) + + // focus the next input if the current input is filled + if (processedValue.length === charLength && index < length - 1) { + inputRefs.current[index + 1].focus() + } + }, [inputs, charLength, upperCase, onChange, length]) + + const handlePaste = useCallback((e) => { + e.preventDefault() + const pastedValues = e.clipboardData.getData('text').slice(0, length) + const processedValues = upperCase ? pastedValues.toUpperCase() : pastedValues + const chars = processedValues.split('') + + const newInputs = [...inputs] + chars.forEach((char, i) => { + newInputs[i] = char.slice(0, charLength) + }) + + updateInputs(newInputs) + inputRefs.current[length - 1]?.focus() // simulating the paste by focusing the last input + }, [inputs, length, charLength, upperCase, updateInputs]) + + const handleKeyDown = useCallback((e, index) => { + switch (e.key) { + case 'Backspace': { + e.preventDefault() + const newInputs = [...inputs] + // if current input is empty move focus to the previous input else clear the current input + const targetIndex = inputs[index] === '' && index > 0 ? index - 1 : index + newInputs[targetIndex] = '' + updateInputs(newInputs) + inputRefs.current[targetIndex]?.focus() + break + } + case 'ArrowLeft': { + if (index > 0) { // focus the previous input if it's not the first input + e.preventDefault() + inputRefs.current[index - 1]?.focus() + } + break + } + case 'ArrowRight': { + if (index < length - 1) { // focus the next input if it's not the last input + e.preventDefault() + inputRefs.current[index + 1]?.focus() + } + break + } + } + }, [inputs, length, updateInputs]) + + return ( + +
+ {inputs.map((value, index) => ( + { inputRefs.current[index] = el }} + onChange={(formik, e) => handleChange(formik, e, index)} + onKeyDown={e => handleKeyDown(e, index)} + onPaste={e => handlePaste(e, index)} + style={{ + textAlign: 'center', + maxWidth: `${charLength * 44}px` // adjusts the max width of the input based on the charLength + }} + prepend={showSequence && {index + 1}} // show the index of the input + hideError + {...props} + /> + ))} +
+
+ {hideError && meta.touched && meta.error && ( // custom error message is showed if hideError is true + + {meta.error} + + )} +
+
+ ) +} + export const ClientInput = Client(Input) export const ClientCheckbox = Client(Checkbox) diff --git a/components/login.js b/components/login.js index 3f43f8a0..3d3d1d84 100644 --- a/components/login.js +++ b/components/login.js @@ -20,6 +20,7 @@ export function EmailLoginForm ({ text, callbackUrl, multiAuth }) { }} schema={emailSchema} onSubmit={async ({ email }) => { + window.sessionStorage.setItem('callback', JSON.stringify({ email, callbackUrl })) signIn('email', { email, callbackUrl, multiAuth }) }} > @@ -41,7 +42,7 @@ const authErrorMessages = { OAuthCallback: 'Error handling OAuth response. Try again or choose a different method.', OAuthCreateAccount: 'Could not create OAuth account. Try again or choose a different method.', EmailCreateAccount: 'Could not create Email account. Try again or choose a different method.', - Callback: 'Error in callback handler. Try again or choose a different method.', + Callback: 'Try again or choose a different method.', OAuthAccountNotLinked: 'This auth method is linked to another account. To link to this account first unlink the other account.', EmailSignin: 'Failed to send email. Make sure you entered your email address correctly.', CredentialsSignin: 'Auth failed. Try again or choose a different method.', diff --git a/lib/constants.js b/lib/constants.js index a2e2f28f..48cf0cda 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -197,3 +197,5 @@ export const ZAP_UNDO_DELAY_MS = 5_000 export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 150_000 export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000 + +export const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' diff --git a/lib/validate.js b/lib/validate.js index 12bce77a..7002b179 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -382,6 +382,10 @@ export const emailSchema = object({ email: string().email('email is no good').required('required') }) +export const emailTokenSchema = object({ + token: string().required('required').trim().matches(/^[0-9a-z]{6}$/i, 'must be 6 alphanumeric characters') +}) + export const urlSchema = object({ url: string().url().required('required') }) diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index f2dcfde3..f3773a67 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { createHash, randomBytes } from 'node:crypto' import NextAuth from 'next-auth' import CredentialsProvider from 'next-auth/providers/credentials' import GitHubProvider from 'next-auth/providers/github' @@ -15,6 +15,7 @@ import { notifyReferral } from '@/lib/webPush' import { hashEmail } from '@/lib/crypto' import * as cookie from 'cookie' import { multiAuthMiddleware } from '@/pages/api/graphql' +import { BECH32_CHARSET } from '@/lib/constants' /** * Stores userIds in user table @@ -272,6 +273,8 @@ const getProviders = res => [ EmailProvider({ server: process.env.LOGIN_EMAIL_SERVER, from: process.env.LOGIN_EMAIL_FROM, + maxAge: 5 * 60, // expires in 5 minutes + generateVerificationToken: generateRandomString, sendVerificationRequest }) ] @@ -321,6 +324,40 @@ export const getAuthOptions = (req, res) => ({ user.email = email } return user + }, + useVerificationToken: async ({ identifier, token }) => { + // we need to find the most recent verification request for this email/identifier + const verificationRequest = await prisma.verificationToken.findFirst({ + where: { + identifier, + attempts: { + lt: 2 // count starts at 0 + } + }, + orderBy: { + createdAt: 'desc' + } + }) + + if (!verificationRequest) throw new Error('No verification request found') + + if (verificationRequest.token === token) { // if correct delete the token and continue + await prisma.verificationToken.delete({ + where: { id: verificationRequest.id } + }) + return verificationRequest + } + + await prisma.verificationToken.update({ + where: { id: verificationRequest.id }, + data: { attempts: { increment: 1 } } + }) + + await prisma.verificationToken.deleteMany({ + where: { id: verificationRequest.id, attempts: { gte: 2 } } + }) + + return null } }, session: { @@ -366,9 +403,22 @@ export default async (req, res) => { await NextAuth(req, res, getAuthOptions(req, res)) } +function generateRandomString (length = 6, charset = BECH32_CHARSET) { + const bytes = randomBytes(length) + let result = '' + + // Map each byte to a character in the charset + for (let i = 0; i < length; i++) { + result += charset[bytes[i] % charset.length] + } + + return result +} + async function sendVerificationRequest ({ identifier: email, url, + token, provider }) { let user = await prisma.user.findUnique({ @@ -397,8 +447,8 @@ async function sendVerificationRequest ({ to: email, from, subject: `login to ${site}`, - text: text({ url, site, email }), - html: user ? html({ url, site, email }) : newUserHtml({ url, site, email }) + text: text({ url, token, site, email }), + html: user ? html({ url, token, site, email }) : newUserHtml({ url, token, site, email }) }, (error) => { if (error) { @@ -411,7 +461,7 @@ async function sendVerificationRequest ({ } // Email HTML body -const html = ({ url, site, email }) => { +const html = ({ url, token, site, email }) => { // Insert invisible space into domains and email address to prevent both the // email address and the domain from being turned into a hyperlink by email // clients like Outlook and Apple mail, as this is confusing because it seems @@ -423,8 +473,6 @@ const html = ({ url, site, email }) => { const backgroundColor = '#f5f5f5' const textColor = '#212529' const mainBackgroundColor = '#ffffff' - const buttonBackgroundColor = '#FADA5E' - const buttonTextColor = '#212529' // Uses tables for layout and inline CSS due to email client limitations return ` @@ -439,26 +487,32 @@ const html = ({ url, site, email }) => { - -
- login as ${escapedEmail} + login with ${escapedEmail}
- + + +
login + copy this magic code +
+ ${token} +
- Or copy and paste this link: ${url} + +
Expires in 5 minutes
- If you did not request this email you can safely ignore it. + +
If you did not request this email you can safely ignore it.
@@ -467,28 +521,21 @@ const html = ({ url, site, email }) => { } // Email text body –fallback for email clients that don't render HTML -const text = ({ url, site }) => `Sign in to ${site}\n${url}\n\n` +const text = ({ url, token, site }) => `Sign in to ${site}\ncopy this code: ${token}\n\n\nExpires in 5 minutes` -const newUserHtml = ({ url, site, email }) => { +const newUserHtml = ({ url, token, site, email }) => { const escapedEmail = `${email.replace(/\./g, '​.')}` - const replaceCb = (path) => { - const urlObj = new URL(url) - urlObj.searchParams.set('callbackUrl', path) - return urlObj.href - } - - const dailyUrl = replaceCb('/daily') - const guideUrl = replaceCb('/guide') - const faqUrl = replaceCb('/faq') - const topUrl = replaceCb('/top/stackers/forever') - const postUrl = replaceCb('/post') + const dailyUrl = new URL('/daily', process.env.NEXT_PUBLIC_URL).href + const guideUrl = new URL('/guide', process.env.NEXT_PUBLIC_URL).href + const faqUrl = new URL('/faq', process.env.NEXT_PUBLIC_URL).href + const topUrl = new URL('/top/stackers/forever', process.env.NEXT_PUBLIC_URL).href + const postUrl = new URL('/post', process.env.NEXT_PUBLIC_URL).href // Some simple styling options const backgroundColor = '#f5f5f5' const textColor = '#212529' const mainBackgroundColor = '#ffffff' - const buttonBackgroundColor = '#FADA5E' return ` @@ -606,7 +653,7 @@ const newUserHtml = ({ url, site, email }) => { -
If you know how Stacker News works, click the login button below.
+
If you know how Stacker News works, copy the magic code below.
@@ -635,25 +682,27 @@ const newUserHtml = ({ url, site, email }) => { -
login as ${escapedEmail}
+
login with ${escapedEmail}
- - + - @@ -707,7 +756,7 @@ const newUserHtml = ({ url, site, email }) => { @@ -731,7 +780,7 @@ const newUserHtml = ({ url, site, email }) => { diff --git a/pages/auth/error.js b/pages/auth/error.js index c95a8ae2..5df283bf 100644 --- a/pages/auth/error.js +++ b/pages/auth/error.js @@ -1,7 +1,6 @@ import Image from 'react-bootstrap/Image' import { StaticLayout } from '@/components/layout' import styles from '@/styles/error.module.css' -import LightningIcon from '@/svgs/bolt.svg' import { useRouter } from 'next/router' import Button from 'react-bootstrap/Button' @@ -27,20 +26,15 @@ export default function AuthError ({ error }) { return ( -

This magic link has expired.

-

Get another by logging in.

+

Incorrect magic code

) diff --git a/pages/email.js b/pages/email.js index c3bba919..23b177bf 100644 --- a/pages/email.js +++ b/pages/email.js @@ -1,11 +1,32 @@ import Image from 'react-bootstrap/Image' import { StaticLayout } from '@/components/layout' import { getGetServerSideProps } from '@/api/ssrApollo' +import { useRouter } from 'next/router' +import { useState, useEffect, useCallback } from 'react' +import { Form, SubmitButton, MultiInput } from '@/components/form' +import { emailTokenSchema } from '@/lib/validate' // force SSR to include CSP nonces export const getServerSideProps = getGetServerSideProps({ query: null }) export default function Email () { + const router = useRouter() + const [callback, setCallback] = useState(null) // callback.email, callback.callbackUrl + + useEffect(() => { + setCallback(JSON.parse(window.sessionStorage.getItem('callback'))) + }, []) + + // build and push the final callback URL + const pushCallback = useCallback((token) => { + const params = new URLSearchParams() + if (callback.callbackUrl) params.set('callbackUrl', callback.callbackUrl) + params.set('token', token) + params.set('email', callback.email) + const url = `/api/auth/callback/email?${params.toString()}` + router.push(url) + }, [callback, router]) + return (
@@ -14,8 +35,36 @@ export default function Email () {

Check your email

-

A sign in link has been sent to your email address

+

a magic code has been sent to {callback ? callback.email : 'your email address'}

+ pushCallback(token)} disabled={!callback} />
) } + +export const MagicCodeForm = ({ onSubmit, disabled }) => { + return ( + { + onSubmit(values.token.toLowerCase()) // token is displayed in uppercase but we need to check it in lowercase + }} + > + + login + + ) +} diff --git a/pages/settings/index.js b/pages/settings/index.js index 4f1e912d..5ad2f813 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -858,7 +858,7 @@ function AuthMethods ({ methods, apiKeyEnabled }) { ) - :
+ :
} else if (provider === 'lightning') { return (
+ - + +
- - login - + + copy this magic code +
+ ${token}
-
Or copy and paste this link: ${url}
+
+
Expires in 5 minutes
-
Zap,
Stacker News
+
Yeehaw,
Stacker News
-
P.S. Stacker News loves you!
+
P.S. We're thrilled you're joinin' the posse!