-
-
Notifications
You must be signed in to change notification settings - Fork 117
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
Login with magic code #1818
Changes from 21 commits
2abe1cb
e130705
cfc01c5
c357bc7
2f29f30
33bbdc3
8fb7cdb
7a0e794
3efa20d
841cd9a
8d41679
c65e62b
e663b75
5c26cd6
0b6da66
2ad5d04
6155eef
9658807
a8b8102
5c7e17f
33a9d47
908b70e
4fa221b
766ca22
b0b8a38
390f79b
8085a71
dd2e75c
6527e22
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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' | ||
|
@@ -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 | ||
|
@@ -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 | ||
}) | ||
] | ||
|
@@ -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: { | ||
|
@@ -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) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is more a product of 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). Edit:
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
|
||
|
||
async function sendVerificationRequest ({ | ||
identifier: email, | ||
url, | ||
token, | ||
provider | ||
}) { | ||
let user = await prisma.user.findUnique({ | ||
|
@@ -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) { | ||
|
@@ -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 | ||
|
@@ -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 ` | ||
|
@@ -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> | ||
|
@@ -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, '​.')}` | ||
|
||
const replaceCb = (path) => { | ||
|
@@ -488,7 +536,6 @@ const newUserHtml = ({ url, site, email }) => { | |
const backgroundColor = '#f5f5f5' | ||
const textColor = '#212529' | ||
const mainBackgroundColor = '#ffffff' | ||
const buttonBackgroundColor = '#FADA5E' | ||
|
||
return ` | ||
<!doctype html> | ||
|
@@ -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> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' }} | ||
|
There was a problem hiding this comment.
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:
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):
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ^^