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

[ Refactor ] Store TOTP Data in Session Instead of DatabaseSession #45

Merged
merged 44 commits into from
Feb 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
1a23cb1
add jsdoc
mw10013 Jan 1, 2024
84c1708
generate/send totp
mw10013 Jan 2, 2024
451dd9a
1st authentication phase tests
mw10013 Jan 2, 2024
a06fec1
cleanup
mw10013 Jan 2, 2024
0fdfa92
generateAndSendTOTP
mw10013 Jan 2, 2024
9cf1098
validateTOTP
mw10013 Jan 2, 2024
e51852b
validate magic link test
mw10013 Jan 2, 2024
dd285cc
more validate tests
mw10013 Jan 2, 2024
c5c98bd
invalid and max totp attempts
mw10013 Jan 3, 2024
e5511a7
jwt expired
mw10013 Jan 3, 2024
7865a29
cleanup
mw10013 Jan 3, 2024
978eec8
remove crud
mw10013 Jan 3, 2024
9aa1c0a
remove unused constants
mw10013 Jan 3, 2024
91b0887
Required<>
mw10013 Jan 3, 2024
ae8a8f9
coerce
mw10013 Jan 3, 2024
a892120
TOTPPayload
mw10013 Jan 4, 2024
60b5201
required email
mw10013 Jan 4, 2024
6cf3e18
stale magic-link and attempts tests
mw10013 Jan 4, 2024
f6f5fe1
test custom errors
mw10013 Jan 4, 2024
b660e08
cleanup
mw10013 Jan 4, 2024
5186149
renamed totpFieldKey to codeFieldKey
mw10013 Jan 4, 2024
f47ed6a
update readme
mw10013 Jan 5, 2024
c472abd
update customization doc
mw10013 Jan 5, 2024
492e218
update migration doc
mw10013 Jan 5, 2024
7e1ad22
Remove MagicLinkGenerationOptions
mw10013 Jan 5, 2024
a83360c
validateEmail() returns boolean
mw10013 Jan 5, 2024
aa0c58f
Remove form and request from SendTOTPOptions
mw10013 Jan 5, 2024
fc8b41d
Remove code, magicLink, form, and request from TOTPVerifyParams
mw10013 Jan 5, 2024
77bd12b
Update readme
mw10013 Jan 5, 2024
90e9fec
Update docs
mw10013 Jan 5, 2024
a5dbc75
refactor !(await )
mw10013 Jan 5, 2024
e49ea05
Refactor _generateAndSendTOTP()
mw10013 Jan 5, 2024
a208a32
minor tweaks
dev-xo Jan 6, 2024
5320a46
revert JoseErrors to errors
dev-xo Jan 6, 2024
e99159e
chore: update documentation
dev-xo Jan 6, 2024
0fc32d3
use node import
mw10013 Feb 5, 2024
f65003e
add build to prepare script
mw10013 Feb 5, 2024
0b8a52b
import *
mw10013 Feb 5, 2024
859b500
add pnpm-lock.yaml per https://pnpm.io/git#lockfiles
mw10013 Feb 5, 2024
c9c2d0d
add request and formData to SendTOTPOptions
mw10013 Feb 12, 2024
ea3752d
docs
mw10013 Feb 12, 2024
52caeee
docs
mw10013 Feb 13, 2024
c7304ff
add formData and request to TOTPVerifyParams
mw10013 Feb 14, 2024
dc2d514
docs
mw10013 Feb 14, 2024
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
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
Loading