Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Login with magic code #1818

Merged
merged 29 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2abe1cb
fix: cannot login with email on PWA
Soxasora Jan 14, 2025
e130705
adjust other email templates
Soxasora Jan 14, 2025
cfc01c5
restore manual url on new user email
Soxasora Jan 14, 2025
c357bc7
no padding on button section
Soxasora Jan 14, 2025
2f29f30
cleanup
Soxasora Jan 14, 2025
33bbdc3
generate 6-digit bechh32 token
Soxasora Jan 14, 2025
8fb7cdb
token needs to be fed as lower case; validator case insensitive
Soxasora Jan 15, 2025
7a0e794
delete token if user has failed 3 times
Soxasora Jan 16, 2025
3efa20d
proposal: context-independent error page
Soxasora Jan 16, 2025
841cd9a
include expiration time on email page message
Soxasora Jan 16, 2025
8d41679
add expiration time to emails
Soxasora Jan 16, 2025
c65e62b
independent checkPWA function
Soxasora Jan 16, 2025
e663b75
restore token deletion if successful auth
Soxasora Jan 16, 2025
5c26cd6
final cleanup: remove unused function
Soxasora Jan 16, 2025
0b6da66
compact useVerificationToken
Soxasora Jan 20, 2025
2ad5d04
email.js: magic code for non-PWA users
Soxasora Jan 21, 2025
6155eef
adjust email templates
Soxasora Jan 21, 2025
9658807
MultiInput component; magic code via MultiInput
Soxasora Jan 29, 2025
a8b8102
Merge branch 'master' into magic_login
Soxasora Jan 29, 2025
5c7e17f
hotfix: revert length testing; larger width for inputs
Soxasora Jan 29, 2025
33a9d47
Merge branch 'master' into magic_login
huumn Jan 30, 2025
908b70e
manual bech32 token generation; no upperCase
Soxasora Jan 30, 2025
4fa221b
reverting to string concatenation
Soxasora Jan 30, 2025
766ca22
layout tweaks, fix error placement
Soxasora Jan 30, 2025
b0b8a38
Merge branch 'master' into magic_login
huumn Jan 31, 2025
390f79b
pastable inputs
Soxasora Jan 31, 2025
8085a71
Merge branch 'master' into magic_login
huumn Feb 2, 2025
dd2e75c
small nit fixes
huumn Feb 3, 2025
6527e22
less ambiguous error path
huumn Feb 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions components/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) &&
Expand Down Expand Up @@ -1241,5 +1241,102 @@ 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 newValue = upperCase // convert the input to uppercase if upperCase is true
? e.target.value.slice(-charLength).toUpperCase()
: e.target.value.slice(-charLength)

const newInputs = [...inputs]
newInputs[index] = newValue
updateInputs(newInputs)

// focus the next input if the current input is filled
if (newValue.length === charLength && index < length - 1) {
inputRefs.current[index + 1].focus()
}
}, [inputs, charLength, upperCase, onChange, length])

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 gap-2'>
{inputs.map((value, index) => (
<InputInner
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)}
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)
1 change: 1 addition & 0 deletions components/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}}
>
Expand Down
4 changes: 4 additions & 0 deletions lib/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 character')
})

export const urlSchema = object({
url: string().url().required('required')
})
Expand Down
97 changes: 73 additions & 24 deletions pages/api/auth/[...nextauth].js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 } from 'bech32'

/**
* Stores userIds in user table
Expand Down Expand Up @@ -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: randomizeToken,
sendVerificationRequest
})
]
Expand Down Expand Up @@ -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
},
orderBy: {
createdAt: 'desc'
}
})

if (!verificationRequest) return null

if (verificationRequest.token === token) { // if correct delete the token and continue
await prisma.verificationToken.delete({
where: { id: verificationRequest.id }
})
return verificationRequest
}

const newAttempts = verificationRequest.attempts + 1
if (newAttempts > 3) { // the moment the user has tried 3 times, delete the token
await prisma.verificationToken.delete({
where: { id: verificationRequest.id }
})
} else { // otherwise, just increment the failed attempts
await prisma.verificationToken.update({
where: { id: verificationRequest.id },
data: { attempts: newAttempts }
})
}

return null
}
},
session: {
Expand Down Expand Up @@ -366,9 +403,15 @@ export default async (req, res) => {
await NextAuth(req, res, getAuthOptions(req, res))
}

function randomizeToken () {
const words = bech32.toWords(Buffer.from(randomBytes(3)))
return bech32.encode('token', words).slice(6, 12)
Copy link
Member

@huumn huumn Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want to use the bech32 encoding lib:

  1. I don't understand it
  2. it has features that we don't need like prefix and checksum

I suggested bech32 for the alphanumeric character set because in lower case, none of the characters can be confused for other numbers/letters. For upper case, there might be a better character set, but basically we want something like the following to generate the string (we have no need for a checksum):

function generateRandomString(length = 6, charset) {
  // Default charset if none provided
  charset = charset || 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
  
  const bytes = crypto.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;
}

Copy link
Member Author

@Soxasora Soxasora Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay! I juggled around the bech32 library because we were already using it (appropriately) for other stuff. I'll gladly switch to manual generation and avoid shenanigans.
Didn't know about avoiding confusion with uppercase characters but I see it now, switching asap!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI Probably not going to get to QA until later today. Troubleshooting weird lightning stuff.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't get to it at all today - add to #1853 that the lnd cert expired, paginated comments deployment caused a meltdown and had weird ux issues.

The fixes you made look good though. I'll QA first thing tomorrow hopefully.

Copy link
Member Author

@Soxasora Soxasora Jan 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A signal from the universe that paginated comments are 🔥🔥🔥, jokes aside don't worry, this PR ain't going somewhere else while we sleep ^^

}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to think more if 24 bits is strong enough even if the link is only valid for 5 mins. Need to do some research how other services deal with the low entropy 🤔

Using bech32 encoding to go from random bytes to guaranteed alphanumeric characters is also an interesting choice. I think I know why you decided to use bech32 but can you elaborate?

Copy link
Member Author

@Soxasora Soxasora Jan 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more a product of better safe than sorry. Discussing with @huumn we agreed that bech32 encoding would produce a safer token than a standard 6-digit or even an 8-digit one.

The real reason is the resulting entropy, there are 32 possible characters per every 'digit', 32^6 = 1.073.741.824 (auto calc came in clutch lol).
24-bit or 32-bit entropy will result anyway in 32^6 after slicing!1

Edit:

go from random bytes to guaranteed alphanumeric characters

I realized I didn't address this, it's just one way to randomize the starting seed, we could choose whatever in place of that

Footnotes

  1. if I'm not mistaken


async function sendVerificationRequest ({
identifier: email,
url,
token,
provider
}) {
let user = await prisma.user.findUnique({
Expand All @@ -391,14 +434,15 @@ async function sendVerificationRequest ({
const { server, from } = provider

const site = new URL(url).host
const code = token.toUpperCase()

nodemailer.createTransport(server).sendMail(
{
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, code, site, email }),
html: user ? html({ url, code, site, email }) : newUserHtml({ url, code, site, email })
},
(error) => {
if (error) {
Expand All @@ -411,7 +455,7 @@ async function sendVerificationRequest ({
}

// Email HTML body
const html = ({ url, site, email }) => {
const html = ({ url, code, 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
Expand All @@ -423,8 +467,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 `
Expand All @@ -439,26 +481,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>${code}</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>
Expand All @@ -467,9 +515,9 @@ 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, code, site }) => `Sign in to ${site}\ncopy this code: ${code}\n\n\nExpires in 5 minutes`

const newUserHtml = ({ url, site, email }) => {
const newUserHtml = ({ url, code, site, email }) => {
const escapedEmail = `${email.replace(/\./g, '&#8203;.')}`

const replaceCb = (path) => {
Expand All @@ -488,7 +536,6 @@ const newUserHtml = ({ url, site, email }) => {
const backgroundColor = '#f5f5f5'
const textColor = '#212529'
const mainBackgroundColor = '#ffffff'
const buttonBackgroundColor = '#FADA5E'

return `
<!doctype html>
Expand Down Expand Up @@ -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>${code}</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>
Expand Down
4 changes: 2 additions & 2 deletions pages/auth/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ 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'>Where did the magic go?</h2>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a placeholder for eventual stackernews-themed sentences 👀

<h4 className='text-muted text-center pt-2'>Get another magic code by logging in or try again by going back.</h4>
<Button
className='align-items-center my-3'
style={{ borderWidth: '2px' }}
Expand Down
Loading