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

enforce MFA for all accounts #59

Merged
merged 23 commits into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 5 additions & 4 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,15 @@ jobs:
run: docker compose exec mgmnt-app npm run ci
- name: Add code coverage to actions summary
run: |
cat tmp/code-coverage/merged/coverage-summary.md >> $GITHUB_STEP_SUMMARY
cat tmp/code-coverage/merged/coverage-details.md >> $GITHUB_STEP_SUMMARY
cat tests/coverage/code-coverage/merged/coverage-summary.md >> $GITHUB_STEP_SUMMARY
cat tests/coverage/code-coverage/merged/coverage-details.md >> $GITHUB_STEP_SUMMARY
- name: Coverage Diff
uses: greatwizard/coverage-diff-action@v1
uses: bultkrantz/coverage-diff-action@v5.0.1
if: always()
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
coverage-filename: tmp/code-coverage/merged/coverage-summary.json
base-summary-filename: base-summary.json
coverage-filename: tests/coverage/code-coverage/merged/coverage-summary.json
- name: Test build
run: |
echo NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} > .env
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ yarn-error.log*
.npm
.eslintcache
package-lock.json
coverage/
tests/coverage/
playwright-report/
src/styles/generated
./tests/fixtures/temp/large-file
./tests/fixtures/temp/large-file
6 changes: 6 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,10 @@ export default withSentryConfig(nextConfig, {
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: true,

experimental: {
turbo: {
// ...
},
},
})
741 changes: 393 additions & 348 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"next-swagger-doc": "^0.4",
"papaparse": "^5.5.2",
"pg": "^8.13.1",
"qrcode.react": "^4.2.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-icons": "^5.4.0",
Expand All @@ -86,7 +87,6 @@
"@types/debug": "^4.1.12",
"@types/react": "19.0.8",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.5",
"dotenv": "*",
"eslint": "^9.19.0",
"eslint-config-next": "*",
Expand Down
47 changes: 25 additions & 22 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,33 @@ const reporters: ReporterDescription[] = []
if (process.argv.includes('--ui')) {
reporters.push(['list'])
} else {
reporters.push([
'monocart-reporter',
{
outputFile: path.resolve('./tmp/test-results/e2e/coverage.html'),
coverage: {
outputDir: path.resolve('./tmp/code-coverage/e2e'),
entryFilter: (entry: { url: string; source: string }) => {
return entry.url.match(/\/chunks\/src/) && !entry.source.match(/TURBOPACK_CHUNK_LISTS/)
},
sourceFilter: testsCoverageSourceFilter,
reports: [
'raw',
'v8',
'console-summary',
[
'lcovonly',
{
file: 'lcov/code-coverage.lcov.info',
},
reporters.push(
[
'monocart-reporter',
{
outputFile: path.resolve('./tests/coverage/test-results/e2e/coverage.html'),
coverage: {
outputDir: path.resolve('./tests/coverage/code-coverage/e2e'),
entryFilter: (entry: { url: string; source: string }) => {
return entry.url.match(/\/chunks\/src/) && !entry.source.match(/TURBOPACK_CHUNK_LISTS/)
},
sourceFilter: testsCoverageSourceFilter,
reports: [
'raw',
'v8',
'console-summary',
[
'lcovonly',
{
file: 'lcov/code-coverage.lcov.info',
},
],
],
],
},
},
},
])
],
['list'],
)
}

export default defineConfig({
Expand Down
146 changes: 146 additions & 0 deletions src/app/account/mfa/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
'use client'

import { useUser } from '@clerk/nextjs'
import { TOTPResource } from '@clerk/types'
import * as React from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { GenerateBackupCodes } from '../backup-codes'
import { useForm } from '@mantine/form'
import { Button, TextInput, Text, Flex, Container } from '@mantine/core'
import { errorToString, reportError } from '@/components/errors'
import { Panel } from '@/components/panel'
import { ButtonLink } from '@/components/links'

type AddTotpSteps = 'add' | 'verify' | 'backupcodes' | 'success'

type DisplayFormat = 'qr' | 'uri'

function AddTotpScreen({ setStep }: { setStep: React.Dispatch<React.SetStateAction<AddTotpSteps>> }) {
const { user } = useUser()
const [totp, setTOTP] = React.useState<TOTPResource | undefined>(undefined)
const [displayFormat, setDisplayFormat] = React.useState<DisplayFormat>('qr')

React.useEffect(() => {
void user
?.createTOTP()
.then((totp: TOTPResource) => {
setTOTP(totp)
})
.catch((err) => reportError(err, 'Error generating MFA'))
}, []) // eslint-disable-line react-hooks/exhaustive-deps

return (
<Panel title="Add MFA">
<Flex gap="md" mb="lg" align={'flex-end'}>
{totp && displayFormat === 'qr' && (
<>
<div>
<QRCodeSVG value={totp?.uri || ''} size={200} />
</div>
<Button onClick={() => setDisplayFormat('uri')}>Use URI instead</Button>
</>
)}

{totp && displayFormat === 'uri' && (
<>
<div>
<p>{totp.uri}</p>
</div>
<Button onClick={() => setDisplayFormat('qr')}>Use QR Code instead</Button>
</>
)}

<Button onClick={() => setStep('add')}>Re generate</Button>
</Flex>
<p>Once you have set up your authentication app, verify your code</p>
<Button onClick={() => setStep('verify')}>Verify</Button>
</Panel>
)
}

function VerifyTotpScreen({ setStep }: { setStep: React.Dispatch<React.SetStateAction<AddTotpSteps>> }) {
const { user } = useUser()

const form = useForm({
mode: 'uncontrolled',
initialValues: {
code: '',
},
validate: {
code: (c: string) => (String(Number(c)).length != 6 ? 'Code must be six digits' : null),
},
})

const verifyTotp = async (e: { code: string }) => {
try {
await user?.verifyTOTP({ code: e.code })
setStep('backupcodes')
} catch (err: unknown) {
form.setErrors({ code: errorToString(err) || 'Invalid Code' })
}
}

return (
<Panel gap="lg" maw={400} title="Verify MFA code">
<form onSubmit={form.onSubmit(verifyTotp)}>
<TextInput
mb="lg"
autoFocus
maxLength={8}
name="code"
label="Enter the code from your authentication app"
placeholder="000 000"
{...form.getInputProps('code')}
/>
<Flex gap="lg" justify={'center'}>
<Button variant="light" onClick={() => setStep('add')}>
Retry
</Button>
<Button type="submit">Verify code</Button>
</Flex>
</form>
</Panel>
)
}

function BackupCodeScreen({ setStep }: { setStep: React.Dispatch<React.SetStateAction<AddTotpSteps>> }) {
return (
<Panel title="Verification was a success!">
<Text>
Save this list of backup codes somewhere safe in case you need to access your account in an emergency
</Text>
<GenerateBackupCodes />
<Button onClick={() => setStep('success')}>Finish</Button>
</Panel>
)
}

function SuccessScreen() {
return (
<Panel title="Success!">
<Text>You have successfully added TOTP MFA with an authentication application.</Text>

<ButtonLink href="/">Return to homepage</ButtonLink>
</Panel>
)
}

export default function AddMFaScreen() {
const [step, setStep] = React.useState<AddTotpSteps>('add')
const { isLoaded, user } = useUser()

if (!isLoaded) return null

if (!user) {
return <p>You must be logged in to access this page</p>
}

return (
<Container>
{step === 'add' && <AddTotpScreen setStep={setStep} />}
{step === 'verify' && <VerifyTotpScreen setStep={setStep} />}
{step === 'backupcodes' && <BackupCodeScreen setStep={setStep} />}
{step === 'success' && <SuccessScreen />}
</Container>
)
}
46 changes: 46 additions & 0 deletions src/app/account/mfa/backup-codes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as React from 'react'
import { BackupCodeResource } from '@clerk/types'
import { reportError } from '@/components/errors'
import { useUser } from '@clerk/nextjs'

// Generate and display backup codes
export function GenerateBackupCodes() {
const { user } = useUser()
const [backupCodes, setBackupCodes] = React.useState<BackupCodeResource | undefined>(undefined)

const [loading, setLoading] = React.useState(false)

React.useEffect(() => {
if (backupCodes) {
return
}

setLoading(true)
void user
?.createBackupCode()
.then((backupCode: BackupCodeResource) => {
setBackupCodes(backupCode)
setLoading(false)
})
.catch((err) => {
reportError(err, 'Failed to generate backup codes')
setLoading(false)
})
}, [backupCodes, user])

if (loading) {
return <p>Loading...</p>
}

if (!backupCodes) {
return <p>There was a problem generating backup codes</p>
}

return (
<ol>
{backupCodes.codes.map((code, index) => (
<li key={index}>{code}</li>
))}
</ol>
)
}
75 changes: 75 additions & 0 deletions src/app/account/mfa/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client'

import * as React from 'react'
import { useClerk, useUser } from '@clerk/nextjs'
import { Link } from '@/components/links'
import { Container, Button, Flex, Text, Title } from '@mantine/core'
import { GenerateBackupCodes } from './backup-codes'
import { Panel } from '@/components/panel'

const HasMFA = () => {
return (
<Container>
<Panel title="MFA is enabled">
<Text>You have successfully enabled MFA on your account</Text>
<Link href="/" display="inline-block" mt="md">
Return to homepage
</Link>
</Panel>
</Container>
)
}

export default function ManageMFA() {
const { openUserProfile } = useClerk()
const { isLoaded, user } = useUser()
const [showNewCodes, setShowNewCodes] = React.useState(false)

if (!isLoaded) return null

if (!user) {
return <p>You must be logged in to access this page</p>
}

if (user.twoFactorEnabled && !window.location.search.includes('TESTING_FORCE_NO_MFA')) return <HasMFA />

return (
<Container>
<Panel title="MFA is required">
<Title order={4}>In order to use SafeInsights, your account must have MFA enabled</Title>

<Flex gap="md">
<Link href="/account/mfa/app">
<Button>Add MFA with an authenticator app</Button>
</Link>

{user.phoneNumbers.length ? (
<Link href="/account/mfa/sms">
<Button>Add MFA using SMS</Button>
</Link>
) : (
<Flex>
<p>You could use SMS MFA if you have a phone number entered on your account.</p>
<Button onClick={() => openUserProfile()}>
Open user profile to add a new phone number
</Button>
</Flex>
)}
</Flex>

{/* Manage backup codes */}
{user.backupCodeEnabled && user.twoFactorEnabled && (
<Text my="md">
Generate new backup codes? - <Button onClick={() => setShowNewCodes(true)}>Generate</Button>
</Text>
)}
{showNewCodes && (
<>
<GenerateBackupCodes />
<Button onClick={() => setShowNewCodes(false)}>Done</Button>
</>
)}
</Panel>
</Container>
)
}
Loading