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 <k00b@stacker.news>
This commit is contained in:
soxa 2025-02-04 01:41:01 +01:00 committed by GitHub
parent 89187db1ea
commit be7c702602
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 268 additions and 52 deletions

View File

@ -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 (
<FormGroup label={label} className={groupClassName}>
<div className='d-flex flex-row justify-content-center gap-2'>
{inputs.map((value, index) => (
<InputInner
inputGroupClassName='w-auto'
name={name}
key={index}
type={inputType}
value={value}
innerRef={(el) => { 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 && <InputGroup.Text>{index + 1}</InputGroup.Text>} // show the index of the input
hideError
{...props}
/>
))}
</div>
<div>
{hideError && meta.touched && meta.error && ( // custom error message is showed if hideError is true
<BootstrapForm.Control.Feedback type='invalid' className='d-block'>
{meta.error}
</BootstrapForm.Control.Feedback>
)}
</div>
</FormGroup>
)
}
export const ClientInput = Client(Input)
export const ClientCheckbox = Client(Checkbox)

View File

@ -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.',

View File

@ -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'

View File

@ -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')
})

View File

@ -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 }) => {
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
login as <strong>${escapedEmail}</strong>
login with <strong>${escapedEmail}</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; text-decoration: none;border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBackgroundColor}; display: inline-block; font-weight: bold;">login</a></td>
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
copy this magic code
</td>
<tr><td height="10px"></td></tr>
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 36px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
<strong>${token}</strong>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
Or copy and paste this link: <a href="#" style="text-decoration:none; color:${textColor}">${url}</a>
<td align="center" style="font-size:0px;padding:0px 20px;word-break:break-word;">
<div style="font-family:Arial, sans-serif;font-size:11px;line-height:22px;text-align:center;color:#55575d;">Expires in 5 minutes</div>
</td>
</tr>
<tr>
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 10px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
If you did not request this email you can safely ignore it.
<td align="center" style="font-size:0px;padding:0px 20px;word-break:break-word;">
<div style="font-family:Arial, sans-serif;font-size:11px;line-height:22px;text-align:center;color:#55575d;">If you did not request this email you can safely ignore it.</div>
</td>
</tr>
</table>
@ -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, '&#8203;.')}`
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 `
<!doctype html>
@ -606,7 +653,7 @@ const newUserHtml = ({ url, site, email }) => {
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Helvetica, Arial, sans-serif;font-size:16px;line-height:22px;text-align:left;color:#000000;">If you know how Stacker News works, click the login button below.</div>
<div style="font-family:Helvetica, Arial, sans-serif;font-size:16px;line-height:22px;text-align:left;color:#000000;">If you know how Stacker News works, copy the magic code below.</div>
</td>
</tr>
<tr>
@ -635,25 +682,27 @@ const newUserHtml = ({ url, site, email }) => {
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Helvetica, Arial, sans-serif;font-size:18px;line-height:1;text-align:center;color:#000000;">login as <b>${escapedEmail}</b></div>
<div style="font-family:Helvetica, Arial, sans-serif;font-size:18px;line-height:1;text-align:center;color:#000000;">login with <b>${escapedEmail}</b></div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:30px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" bgcolor="${buttonBackgroundColor}" role="presentation" style="border:none;border-radius:5px;cursor:auto;mso-padding-alt:15px 40px;background:${buttonBackgroundColor};" valign="middle">
<a href="${url}" style="display:inline-block;background:${buttonBackgroundColor};color:${textColor};font-family:Helvetica, Arial, sans-serif;font-size:22px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:15px 40px;mso-padding-alt:0px;border-radius:5px;" target="_blank">
<mj-text align="center" font-family="Helvetica, Arial, sans-serif" font-size="20px"><b font-family="Helvetica, Arial, sans-serif">login</b></mj-text>
</a>
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
copy this magic code
</td>
<tr><td height="10px"></td></tr>
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 36px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
<strong>${token}</strong>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:24px;text-align:center;color:#000000;">Or copy and paste this link: <a href="#" style="text-decoration:none; color:#787878">${url}</a></div>
<td align="center" style="font-size:0px;padding:0px 20px;word-break:break-word;">
<div style="font-family:Arial, sans-serif;font-size:11px;line-height:22px;text-align:center;color:#55575d;">Expires in 5 minutes</div>
</td>
</tr>
</tbody>
@ -707,7 +756,7 @@ const newUserHtml = ({ url, site, email }) => {
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Zap,<br /> Stacker News</div>
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Yeehaw,<br /> Stacker News</div>
</td>
</tr>
</tbody>
@ -731,7 +780,7 @@ const newUserHtml = ({ url, site, email }) => {
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px 25px 0px 25px;word-break:break-word;">
<div style="font-family:Arial, sans-serif;font-size:14px;line-height:28px;text-align:center;color:#55575d;">P.S. Stacker News loves you!</div>
<div style="font-family:Arial, sans-serif;font-size:14px;line-height:28px;text-align:center;color:#55575d;">P.S. We're thrilled you're joinin' the posse!</div>
</td>
</tr>
</tbody>

View File

@ -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 (
<StaticLayout>
<Image className='rounded-1 shadow-sm' width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/double.gif`} fluid />
<h2 className='pt-4'>This magic link has expired.</h2>
<h4 className='text-muted pt-2'>Get another by logging in.</h4>
<h2 className='pt-4'>Incorrect magic code</h2>
<Button
className='align-items-center my-3'
style={{ borderWidth: '2px' }}
id='login'
onClick={() => router.push('/login')}
onClick={() => router.back()}
size='lg'
>
<LightningIcon
width={24}
height={24}
className='me-2'
/>login
try again
</Button>
</StaticLayout>
)

View File

@ -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 (
<StaticLayout>
<div className='p-4 text-center'>
@ -14,8 +35,36 @@ export default function Email () {
<Image className='rounded-1 shadow-sm' width='640' height='302' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/cowboy-saloon.gif`} fluid />
</video>
<h2 className='pt-4'>Check your email</h2>
<h4 className='text-muted pt-2'>A sign in link has been sent to your email address</h4>
<h4 className='text-muted pt-2 pb-4'>a magic code has been sent to {callback ? callback.email : 'your email address'}</h4>
<MagicCodeForm onSubmit={(token) => pushCallback(token)} disabled={!callback} />
</div>
</StaticLayout>
)
}
export const MagicCodeForm = ({ onSubmit, disabled }) => {
return (
<Form
initial={{
token: ''
}}
schema={emailTokenSchema}
onSubmit={(values) => {
onSubmit(values.token.toLowerCase()) // token is displayed in uppercase but we need to check it in lowercase
}}
>
<MultiInput
length={6}
charLength={1}
name='token'
required
autoFocus
groupClassName='d-flex flex-column justify-content-center gap-2'
inputType='text'
hideError // hide error message on every input, allow custom error message
disabled={disabled} // disable the form if no callback is provided
/>
<SubmitButton variant='primary' className='px-4' disabled={disabled}>login</SubmitButton>
</Form>
)
}

View File

@ -858,7 +858,7 @@ function AuthMethods ({ methods, apiKeyEnabled }) {
</Button>
</div>
)
: <div key={provider} className='mt-2'><EmailLinkForm /></div>
: <div key={provider} className='mt-2'><EmailLinkForm callbackUrl='/settings' /></div>
} else if (provider === 'lightning') {
return (
<QRLinkButton
@ -910,6 +910,7 @@ export function EmailLinkForm ({ callbackUrl }) {
// then call signIn
const { data } = await linkUnverifiedEmail({ variables: { email } })
if (data.linkUnverifiedEmail) {
window.sessionStorage.setItem('callback', JSON.stringify({ email, callbackUrl }))
signIn('email', { email, callbackUrl })
}
}}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "verification_requests" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0;

View File

@ -1088,6 +1088,7 @@ model VerificationToken {
identifier String
token String @unique(map: "verification_requests.token_unique")
expires DateTime
attempts Int @default(0)
@@unique([identifier, token])
@@map("verification_requests")