Skip to content

Commit

Permalink
[ Refactor ] Store TOTP Data in Session (#45)
Browse files Browse the repository at this point in the history
- Add JSDoc
- Generate and Send TOTP
- Add tests for the 1st authentication phase
- Refactor: Rename function to `generateAndSendTOTP`
- Refactor: Rename function to `validateTOTP`
- Handle JWT expiration
- Remove CRUD operations
- Remove unused constants
- Add coerce utility
- Add `TOTPPayload` structure
- Require email for authentication
- Add tests for stale magic-link and login attempts
- Add test for custom error scenarios
- Refactor: Rename variable `totpFieldKey` to `codeFieldKey`
- Remove `MagicLinkGenerationOptions`
- Refactor: Remove `form` and `request` from `SendTOTPOptions`
- Refactor: Remove `code`, `magicLink`, `form`, and `request` from `TOTPVerifyParams`
- Switch to Node.js import syntax
- Add build step to prepare script
- Update `SendTOTPOptions` with `request` and `formData`
- Update `TOTPVerifyParams` with `request` and `formData`
- Update migration documentation
- Update documentation

---------

Co-authored-by: Dev XO <[email protected]>
  • Loading branch information
mw10013 and dev-xo authored Feb 24, 2024
1 parent 2e3f135 commit 773935e
Show file tree
Hide file tree
Showing 11 changed files with 4,843 additions and 1,001 deletions.
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# Package Managers.
package-lock.json
yarn.lock
pnpm-lock.yaml
pnpm-lock.yml
node_modules

# Editor Configs.
Expand Down
190 changes: 78 additions & 112 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,8 @@ npm install remix-auth-totp

## Usage

Remix Auth TOTP exports four required methods:
Remix Auth TOTP exports one required method:

- `createTOTP` - Create the TOTP data in the database.
- `readTOTP` - Read the TOTP data from the database.
- `updateTOTP` - Update the TOTP data in the database.
- `sendTOTP` - Sends the TOTP code to the user via email or any other method.

Here's a basic overview of the authentication process.
Expand All @@ -63,41 +60,6 @@ Here's a basic overview of the authentication process.
Let's see how we can implement the Strategy into our Remix App.

## Database

We'll require a database to store our TOTP data.

For this example we'll use Prisma ORM with a SQLite database. As long as your database supports the following fields, you can use any database of choice.

```ts
/**
* Fields:
* - `hash`: String
* - `active`: Boolean
* - `attempts`: Int (Number)
* - `expiresAt`: DateTime (Date)
*/
model Totp {
// The encrypted data used to generate the OTP.
hash String @unique

// The status of the TOTP.
// Used internally / programmatically to invalidate TOTPs.
active Boolean

// The input attempts of the TOTP.
// Used internally to invalidate TOTPs after a certain amount of attempts.
attempts Int

// The expiration date of the TOTP.
// Used programmatically to invalidate unused TOTPs.
expiresAt DateTime

// Index for expiresAt
@@index([expiresAt])
}
```

## Email Service

We'll require an Email Service to send the codes to our users. Feel free to use any service of choice, such as [Resend](https://resend.com), [Mailgun](https://www.mailgun.com), [Sendgrid](https://sendgrid.com), etc. The goal is to have a sender function similar to the following one.
Expand Down Expand Up @@ -172,20 +134,15 @@ type User = {
email: string
}

export let authenticator = new Authenticator<User>(sessionStorage, {
throwOnError: true,
})
export let authenticator = new Authenticator<User>(sessionStorage)

authenticator.use(
new TOTPStrategy(
{
secret: process.env.ENCRYPTION_SECRET || 'NOT_A_STRONG_SECRET',
createTOTP: async (data, expiresAt) => {},
readTOTP: async (hash) => {},
updateTOTP: async (hash, data, expiresAt) => {},
sendTOTP: async ({ email, code, magicLink, user, form, request }) => {},
sendTOTP: async ({ email, code, magicLink }) => {},
},
async ({ email, code, form, magicLink, request }) => {},
async ({ email }) => {},
),
)
```
Expand All @@ -195,46 +152,24 @@ authenticator.use(
### 2: Implementing the Strategy Logic.

The Strategy Instance requires the following four methods: `createTOTP`, `readTOTP`, `updateTOTP`, `sendTOTP`.
The Strategy Instance requires the following method: `sendTOTP`.

```ts
authenticator.use(
new TOTPStrategy(
{
secret: process.env.ENCRYPTION_SECRET,

createTOTP: async (data, expiresAt) => {
await prisma.totp.create({ data: { ...data, expiresAt } })

try {
// Optional - Delete expired TOTP records.
// Feel free to handle this on a scheduled task.
await prisma.totp.deleteMany({ where: { expiresAt: { lt: new Date() } } })
} catch (error) {
console.warn('Error deleting expired TOTP records', error)
}
},
readTOTP: async (hash) => {
// Get the TOTP data from the database.
return await db.totp.findUnique({ where: { hash } })
},
updateTOTP: async (hash, data, expiresAt) => {
// Update the TOTP data in the database.
// No need to update expiresAt since it does not change after createTOTP().
await db.totp.update({ where: { hash }, data })
},
sendTOTP: async ({ email, code, magicLink }) => {
// Send the TOTP code to the user.
await sendEmail({ email, code, magicLink })
},
},
async ({ email, code, magicLink, form, request }) => {},
async ({ email }) => {},
),
)
```

All these CRUD methods should be replaced and adapted with the ones provided by our database.

### 3. Creating and Storing the User.

The Strategy returns a `verify` method that allows handling our own logic. This includes creating the user, updating the user, etc.<br />
Expand All @@ -249,12 +184,7 @@ authenticator.use(
// createTOTP: async (data) => {},
// ...
},
async ({ email, code, magicLink, form, request }) => {
// You can determine whether the user is authenticating
// via OTP code submission or Magic-Link URL and run your own logic.
if (form) console.log('Optional form submission logic.')
if (magicLink) console.log('Optional magic-link submission logic.')

async ({ email }) => {
// Get user from database.
let user = await db.user.findFirst({
where: { email },
Expand Down Expand Up @@ -294,13 +224,12 @@ export async function loader({ request }: DataFunctionArgs) {
successRedirect: '/account',
})

const cookie = await getSession(request.headers.get('Cookie'))
const authEmail = cookie.get('auth:email')
const authError = cookie.get(authenticator.sessionErrorKey)
const session = await getSession(request.headers.get('Cookie'))
const authError = session.get(authenticator.sessionErrorKey)

// Commit session to clear any `flash` error message.
return json(
{ authEmail, authError },
{ authError },
{
headers: {
'set-cookie': await commitSession(session),
Expand All @@ -312,56 +241,94 @@ export async function loader({ request }: DataFunctionArgs) {
export async function action({ request }: DataFunctionArgs) {
await authenticator.authenticate('TOTP', request, {
// The `successRedirect` route it's required.
// ...
// User is not authenticated yet.
// We want to redirect to our verify code form. (/verify-code or any other route).
successRedirect: '/verify',

// The `failureRedirect` route it's required.
// ...
// We want to display any possible error message.
// If not provided, ErrorBoundary will be rendered instead.
failureRedirect: '/login',
})
}

export default function Login() {
let { authEmail, authError } = useLoaderData<typeof loader>()
let { authError } = useLoaderData<typeof loader>()

return (
<div style={{ display: 'flex' flexDirection: 'column' }}>
{/* Email Form. */}
{!authEmail && (
{/* Login Form. */}
<Form method="POST">
<label htmlFor="email">Email</label>
<input type="email" name="email" placeholder="Insert email .." required />
<button type="submit">Send Code</button>
</Form>
)}

{/* Code Verification Form. */}
{authEmail && (
<div style={{ display: 'flex' flexDirection: 'column' }}>
{/* Renders the form that verifies the code. */}
<Form method="POST">
<label htmlFor="code">Code</label>
<input type="text" name="code" placeholder="Insert code .." required />

<button type="submit">Continue</button>
</Form>

{/* Renders the form that requests a new code. */}
{/* Email input is not required, it's already stored in Session. */}
<Form method="POST">
<button type="submit">Request new Code</button>
</Form>
</div>
)}

{/* Email Errors Handling. */}
{!authEmail && (<span>{authError?.message || email?.error}</span>)}

{/* Login Errors Handling. */}
<span>{authError?.message}</span>
</div>
)
}
```

### `verify.tsx`

```tsx
// app/routes/verify.tsx
import type { DataFunctionArgs } from '@remix-run/node'
import { json, redirect } from '@remix-run/node'
import { Form, useLoaderData } from '@remix-run/react'

import { authenticator } from '~/modules/auth/auth.server.ts'
import { getSession, commitSession } from '~/modules/auth/auth-session.server.ts'

export async function loader({ request }: DataFunctionArgs) {
await authenticator.isAuthenticated(request, {
successRedirect: '/account',
})

const session = await getSession(request.headers.get('cookie'))
const authEmail = session.get('auth:email')
const authError = session.get(authenticator.sessionErrorKey)
if (!authEmail) return redirect('/login')

// Commit session to clear any `flash` error message.
return json({ authError }, {
headers: {
'set-cookie': await commitSession(session),
},
})
}

export async function action({ request }: DataFunctionArgs) {
const url = new URL(request.url)
const currentPath = url.pathname

await authenticator.authenticate('TOTP', request, {
successRedirect: currentPath,
failureRedirect: currentPath,
})
}

export default function Verify() {
const { authError } = useLoaderData<typeof loader>()

return (
<div style={{ display: 'flex' flexDirection: 'column' }}>
{/* Code Verification Form */}
<Form method="POST">
<label htmlFor="code">Code</label>
<input type="text" name="code" placeholder="Insert code .." required />
<button type="submit">Continue</button>
</Form>

{/* Renders the form that requests a new code. */}
{/* Email input is not required, it's already stored in Session. */}
<Form method="POST">
<button type="submit">Request new Code</button>
</Form>

{/* Code Errors Handling. */}
{authEmail && (<span>{authError?.message || code?.error}</span>)}
<span>{authError?.message}</span>
</div>
)
}
Expand All @@ -372,7 +339,6 @@ export default function Login() {
```tsx
// app/routes/account.tsx
import type { DataFunctionArgs } from '@remix-run/node'

import { json } from '@remix-run/node'
import { Form, useLoaderData } from '@remix-run/react'
import { authenticator } from '~/modules/auth/auth.server'
Expand Down
Loading

0 comments on commit 773935e

Please sign in to comment.