|
|
@@ -0,0 +1,286 @@ |
|
|
import crypto from "crypto"; |
|
|
import { renderToStaticMarkup } from "react-dom/server"; |
|
|
import createMailgun from "mailgun-js"; |
|
|
import type { ActionFunction, LoaderFunction, Session } from "remix"; |
|
|
import { createCookieSessionStorage, json, redirect } from "remix"; |
|
|
|
|
|
/******************************************************************************* |
|
|
* 1. Before we can do anything, we need to make sure the environment has |
|
|
* everything we need. If anything is missing, we just prevent the app from |
|
|
* starting up. |
|
|
*/ |
|
|
if (!process.env.MAGIC_LINK_SALT) |
|
|
throw new Error("Missing `process.env.MAGIC_LINK_SALT`"); |
|
|
|
|
|
if (!process.env.MAILGUN_KEY) |
|
|
throw new Error("Missing process.env.MAILGUN_KEY"); |
|
|
|
|
|
if (!process.env.MAILGUN_DOMAIN) |
|
|
throw new Error("Missing `process.env.MAILGUN_DOMAIN`"); |
|
|
|
|
|
/******************************************************************************* |
|
|
* 2. It all starts with a "user session". A session is a fancy type of cookie |
|
|
* that references data either in the cookie directly or in some other storage |
|
|
* like a database (and the cookie holds value that can access the other |
|
|
* storage). In our case we're going to keep the data in the cookie itself since |
|
|
* we don't know what kind of database you've got. |
|
|
*/ |
|
|
export let authSession = createCookieSessionStorage({ |
|
|
cookie: { |
|
|
// TODO: use process.env |
|
|
secrets: ["not-very-secret"], |
|
|
path: "/", |
|
|
sameSite: "lax", |
|
|
}, |
|
|
}); |
|
|
|
|
|
// 30 days |
|
|
let sessionMaxAge = /*seconds*/ 60 * /*hrs*/ 24 * /*days*/ 30; |
|
|
|
|
|
/******************************************************************************* |
|
|
* 3. The whole point of authentication is to make sure we have a valid user |
|
|
* before showing them some pages. This function protects pages from |
|
|
* unauthenticated users. You call this from any loader/action that needs |
|
|
* authentication. |
|
|
* |
|
|
* This function will return the user session (with a way to refresh it, we'll |
|
|
* talk about that when you get to (7)). If there isn't a session, it redirects |
|
|
* to the "/auth" route by throwing a redirect response. |
|
|
* |
|
|
* Because you can `throw` a response in Remix, your loaders and actions don't |
|
|
* have to worry about doing the redirects themselves. Code in the loader will |
|
|
* stop executing and this function peforms a redirect right here. |
|
|
* |
|
|
* 7. All future requests to loaders/actions that require a user session will |
|
|
* call this function and they'll get the session instead of a login redirect. |
|
|
* Sessions are stored with cookies which have a "max age" value. This is how |
|
|
* long you want the browser to hang on to the cookie. The `refresh` function |
|
|
* allows loaders and actions to "refresh" the max age so it's always "since the |
|
|
* user last used it". If we didn't refresh, then sessions would always expire |
|
|
* even if the user is on your site every day. |
|
|
*/ |
|
|
export async function getAuthSession( |
|
|
request: Request |
|
|
): Promise<[Session, () => Promise<Headers>]> { |
|
|
let cookie = request.headers.get("cookie"); |
|
|
let session = await authSession.getSession(cookie); |
|
|
|
|
|
if (!session.has("auth")) { |
|
|
throw redirect("/auth", { |
|
|
status: 303, |
|
|
headers: { |
|
|
"auth-redirect": getReferrer(request), |
|
|
}, |
|
|
}); |
|
|
} |
|
|
|
|
|
let refresh = async () => |
|
|
new Headers({ |
|
|
"Set-Cookie": await authSession.commitSession(session, { |
|
|
maxAge: sessionMaxAge, |
|
|
}), |
|
|
}); |
|
|
|
|
|
return [session, refresh]; |
|
|
} |
|
|
|
|
|
/******************************************************************************* |
|
|
* 4. The user is redirected to this loader from `getAuthSession` if they haven't |
|
|
* logged in yet. This loader is also used to validate tokens, but right now there |
|
|
* isn't a token so it just renders the route with a "referrer" so the token can |
|
|
* log them into the right page later. We'll be back here soon for that part. |
|
|
* |
|
|
* Now go to (5) |
|
|
* |
|
|
* 6. After the user clicks the link in their email we end up here again, but |
|
|
* this time we have a token in the URL. If it's valid, we set "auth" in the |
|
|
* session as we redirect to the landing page. We've got a user session! |
|
|
* |
|
|
* You might also do some work with your database here, like create a user |
|
|
* record. |
|
|
* |
|
|
* Now go up to (7) |
|
|
*/ |
|
|
export let signInLoader: LoaderFunction = async ({ request }) => { |
|
|
let magicToken = getMagicToken(request); |
|
|
|
|
|
if (typeof magicToken !== "string") { |
|
|
return json({ landingPage: getReferrer(request) }); |
|
|
} |
|
|
|
|
|
let magicLinkPayload = getMagicLink(magicToken); |
|
|
|
|
|
// might want to create user in the db |
|
|
// might want to create a db session instead of a cookie session |
|
|
// might set the user.id or session.id from a db instead of email |
|
|
let session = await authSession.getSession(); |
|
|
session.set("auth", magicLinkPayload.email); |
|
|
|
|
|
return redirect(magicLinkPayload.landingPage, { |
|
|
headers: { |
|
|
"Set-Cookie": await authSession.commitSession(session, { |
|
|
maxAge: sessionMaxAge, |
|
|
}), |
|
|
}, |
|
|
}); |
|
|
}; |
|
|
|
|
|
/******************************************************************************* |
|
|
* 5. After the user submits the form with their email address, we read the POST |
|
|
* body from the request, validate it, send the email, and finally render the |
|
|
* same route again but this time with action data. The UI then tells them to |
|
|
* check their email. |
|
|
* |
|
|
* No go back up to (6) |
|
|
*/ |
|
|
export let signInAction: ActionFunction = async ({ request }) => { |
|
|
let body = Object.fromEntries(new URLSearchParams(await request.text())); |
|
|
|
|
|
if (typeof body.email !== "string" || body.email.indexOf("@") === -1) { |
|
|
throw json("Missing email", { status: 400 }); |
|
|
} |
|
|
|
|
|
if (typeof body.landingPage !== "string") { |
|
|
throw json("Missing landing page", { status: 400 }); |
|
|
} |
|
|
|
|
|
await sendMagicLinkEmail(body.email, body.landingPage); |
|
|
return json("ok"); |
|
|
}; |
|
|
|
|
|
//////////////////////////////////////////////////////////////////////////////// |
|
|
// helpers |
|
|
function getMagicToken(request: Request) { |
|
|
let { searchParams } = new URL(request.url); |
|
|
return searchParams.get("key"); |
|
|
} |
|
|
|
|
|
function getMagicLink(magicToken: string) { |
|
|
try { |
|
|
return validateMagicLink(magicToken); |
|
|
} catch (e) { |
|
|
throw json("Invalid magic link", { status: 400 }); |
|
|
} |
|
|
} |
|
|
|
|
|
function getReferrer(request: Request) { |
|
|
// This doesn't work with all remix adapters yet, so pick a good default |
|
|
let referrer = request.referrer; |
|
|
if (referrer) { |
|
|
let url = new URL(referrer); |
|
|
return url.pathname + url.search; |
|
|
} |
|
|
return "/dashboard"; |
|
|
} |
|
|
|
|
|
/******************************************************************************* |
|
|
* Encryption of for the email link |
|
|
*/ |
|
|
|
|
|
// TODO: move to env |
|
|
let domain = "http://localhost:3000"; |
|
|
let magicLinkSearchParam = "key"; |
|
|
let linkExpirationTime = 1000 * 60 * 30; |
|
|
let algorithm = "aes-256-ctr"; |
|
|
let ivLength = 16; |
|
|
|
|
|
let encryptionKey = crypto.scryptSync(process.env.MAGIC_LINK_SALT, "salt", 32); |
|
|
|
|
|
function encrypt(text: string) { |
|
|
let iv = crypto.randomBytes(ivLength); |
|
|
let cipher = crypto.createCipheriv(algorithm, encryptionKey, iv); |
|
|
let encrypted = Buffer.concat([cipher.update(text), cipher.final()]); |
|
|
return `${iv.toString("hex")}:${encrypted.toString("hex")}`; |
|
|
} |
|
|
|
|
|
function decrypt(text: string) { |
|
|
let [ivPart, encryptedPart] = text.split(":"); |
|
|
if (!ivPart || !encryptedPart) { |
|
|
throw new Error("Invalid text."); |
|
|
} |
|
|
|
|
|
let iv = Buffer.from(ivPart, "hex"); |
|
|
let encryptedText = Buffer.from(encryptedPart, "hex"); |
|
|
let decipher = crypto.createDecipheriv(algorithm, encryptionKey, iv); |
|
|
let decrypted = Buffer.concat([ |
|
|
decipher.update(encryptedText), |
|
|
decipher.final(), |
|
|
]); |
|
|
return decrypted.toString(); |
|
|
} |
|
|
|
|
|
type MagicLinkPayload = { |
|
|
email: string; |
|
|
landingPage: string; |
|
|
creationDate: string; |
|
|
}; |
|
|
|
|
|
export function generateMagicLink(email: string, landingPage: string) { |
|
|
let payload: MagicLinkPayload = { |
|
|
email, |
|
|
landingPage, |
|
|
creationDate: new Date().toISOString(), |
|
|
}; |
|
|
let stringToEncrypt = JSON.stringify(payload); |
|
|
let encryptedString = encrypt(stringToEncrypt); |
|
|
let url = new URL(domain); |
|
|
url.pathname = "/auth"; |
|
|
url.searchParams.set(magicLinkSearchParam, encryptedString); |
|
|
return url.toString(); |
|
|
} |
|
|
|
|
|
function isMagicLinkPayload(obj: any): obj is MagicLinkPayload { |
|
|
return ( |
|
|
typeof obj === "object" && |
|
|
typeof obj.email === "string" && |
|
|
typeof obj.landingPage === "string" && |
|
|
typeof obj.creationDate === "string" |
|
|
); |
|
|
} |
|
|
|
|
|
export function validateMagicLink(link: string) { |
|
|
let decryptedString = decrypt(link); |
|
|
let payload = JSON.parse(decryptedString); |
|
|
|
|
|
if (!isMagicLinkPayload(payload)) { |
|
|
throw new Error("Invalid magic link"); |
|
|
} |
|
|
|
|
|
let linkCreationDate = new Date(payload.creationDate); |
|
|
let expirationTime = linkCreationDate.getTime() + linkExpirationTime; |
|
|
|
|
|
if (Date.now() > expirationTime) { |
|
|
throw new Error("Invalid magic link"); |
|
|
} |
|
|
|
|
|
return payload; |
|
|
} |
|
|
|
|
|
/******************************************************************************* |
|
|
* Email handled by mailgun |
|
|
*/ |
|
|
let mailgun = createMailgun({ |
|
|
apiKey: process.env.MAILGUN_KEY, |
|
|
domain: process.env.MAILGUN_DOMAIN, |
|
|
}); |
|
|
|
|
|
export async function sendMagicLinkEmail(email: string, landingPage: string) { |
|
|
let link = generateMagicLink(email, landingPage); |
|
|
|
|
|
let html = renderToStaticMarkup( |
|
|
<> |
|
|
<p style={{ fontWeight: "bold" }}>Magic link demo email.</p> |
|
|
<p>(kinda cool we can use JSX to write html email yeah?!)</p> |
|
|
<p> |
|
|
Just click this <a href={link}>link</a> and you're logged in! |
|
|
</p> |
|
|
</> |
|
|
); |
|
|
|
|
|
return mailgun.messages().send({ |
|
|
from: "Remix Magic Link Demo <[email protected]>", |
|
|
to: email, |
|
|
subject: "Login to Local Host!", |
|
|
html, |
|
|
}); |
|
|
} |