This guide provides a complete walkthrough for integrating SMS-based Two-Factor Authentication (2FA) into your RedwoodJS application using the Plivo Messaging API. Adding 2FA enhances security by requiring users to provide a One-Time Password (OTP) sent to their mobile device, significantly reducing the risk of unauthorized account access even if passwords are compromised.
We will build a system where, after initial login (e.g., username/password - implementation not covered here), users are prompted to enter an OTP sent via SMS. We'll cover setting up RedwoodJS, configuring Plivo, implementing API logic for sending and verifying OTPs using Redis for temporary storage, and building the necessary front-end components.
Technologies Used:
- RedwoodJS: A full-stack JavaScript/TypeScript framework for building modern web applications. Its structure separates API and web concerns, making integration clear.
- Plivo: A cloud communications platform providing SMS API capabilities needed to send OTP messages.
- Node.js: The underlying runtime environment for RedwoodJS.
- Prisma: The database toolkit used by RedwoodJS for schema definition and data access.
- Redis: An in-memory data structure store used here for temporary storage of OTPs, ensuring they expire automatically.
- GraphQL: Used by RedwoodJS for API communication between the web and API sides.
System Architecture:
[ User Browser ] <--> [ Redwood Web Side (Pages/Components) ]
| |
| (GraphQL Request) | (GraphQL Mutation Call)
V V
[ Redwood API Side (GraphQL Server + Services) ]
| |
| (Plivo API Call) | (Redis Operations)
V V
[ Plivo SMS API ] <---------> [ Redis Cache ]
|
V
[ User's Mobile Device (SMS) ]
Prerequisites:
- Node.js: Version 18.x or later.
- Yarn: Package manager (installed with Node.js or separately).
- RedwoodJS CLI: Install globally:
npm install -g redwoodjs@latest
- Plivo Account: Sign up for a free trial at Plivo.com.
- Plivo Phone Number: Purchase an SMS-enabled phone number through your Plivo console (Phone Numbers > Buy Numbers). Ensure it's capable of sending messages to your target regions.
- Plivo API Credentials: Obtain your Auth ID and Auth Token from the Plivo console dashboard.
- Redis Instance: A running Redis server accessible from your application (local or cloud-hosted). Get the connection URL (e.g.,
redis://localhost:6379
).
Final Outcome:
By the end of this guide, your RedwoodJS application will have a functional SMS 2FA flow. Users associated with a phone number will be required to verify an OTP sent via Plivo SMS before gaining full access after their initial login step.
1. Setting up the RedwoodJS Project
First, create a new RedwoodJS application if you don't have one already.
# Choose TypeScript when prompted for robustness
yarn create redwood-app ./redwood-plivo-2fa
cd redwood-plivo-2fa
This command scaffolds a new RedwoodJS project with the necessary structure, including api
and web
sides.
2. Plivo and Redis Configuration
We need to install the necessary libraries and configure environment variables to securely store credentials.
1. Install Dependencies:
Install the Plivo Node helper library and the Redis client library on the API side.
yarn workspace api add plivo ioredis
2. Configure Environment Variables:
RedwoodJS uses a .env
file for environment variables. Create one in the project root if it doesn't exist. Add your Plivo credentials, Plivo phone number, and Redis connection URL.
# .env
# Plivo Credentials (Get from Plivo Console Dashboard)
PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
# Plivo SMS-enabled Phone Number (Must be in E.164 format, e.g., +14155551212)
# Replace this example with your actual Plivo number
PLIVO_PHONE_NUMBER=+1xxxxxxxxxx
# Redis Connection URL
# Use redis://localhost:6379 for local development
# Replace with your production/cloud Redis URL for deployment
REDIS_URL=redis://localhost:6379
PLIVO_AUTH_ID
/PLIVO_AUTH_TOKEN
: Found on your Plivo Console dashboard. Used to authenticate API requests.PLIVO_PHONE_NUMBER
: The SMS-enabled number you purchased in Plivo, in E.164 format (e.g.,+14155551212
). This will be the sender ID for OTP messages. Replace the example value.REDIS_URL
: The connection string for your Redis instance. The exampleredis://localhost:6379
is suitable for local development but must be replaced with your production Redis URL when deploying.
Important: Add .env
to your .gitignore
file to prevent accidentally committing sensitive credentials. RedwoodJS automatically loads these variables.
3. Database Schema Modifications
We'll assume you have a User
model. We need to add fields to store the user's phone number (required for sending OTPs) and track their 2FA status.
1. Edit Schema:
Modify your api/db/schema.prisma
file:
// api/db/schema.prisma
datasource db {
provider = ""postgresql"" // Or your chosen provider
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
}
model User {
id Int @id @default(autoincrement())
email String @unique
hashedPassword String? // Assuming you have password auth
salt String? // Assuming you have password auth
resetToken String?
resetTokenExpiresAt DateTime?
// --- Add these fields for 2FA ---
phoneNumber String? @unique // Store in E.164 format (e.g., +14155551212)
isTwoFactorEnabled Boolean @default(false)
// We don't store the OTP itself in the DB, Redis handles temporary storage
// --- End 2FA fields ---
// Add other fields as needed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
phoneNumber
: Stores the user's verified phone number in E.164 format (e.g.,+14155551212
). Make it unique if needed.isTwoFactorEnabled
: A flag indicating if the user has successfully set up and enabled 2FA.
2. Apply Migrations:
Generate and apply the database migration:
yarn rw prisma migrate dev --name add_2fa_fields
This command updates your database schema to include the new fields.
4. API Side Implementation (Services & GraphQL)
Now, let's build the API logic for requesting and verifying OTPs.
1. Create Redis Client Utility:
Create a utility file to manage the Redis connection.
// api/src/lib/redis.ts
import Redis from 'ioredis'
import { logger } from 'src/lib/logger'
let redisInstance: Redis | null = null
export const getRedisClient = (): Redis => {
if (!redisInstance) {
try {
if (!process.env.REDIS_URL) {
throw new Error('REDIS_URL environment variable is not set.')
}
redisInstance = new Redis(process.env.REDIS_URL, {
// Optional: Add more Redis options here if needed
maxRetriesPerRequest: 3,
lazyConnect: true, // Connect only when needed
})
redisInstance.on('error', (err) => {
logger.error({ err }, 'Redis Client Error')
// Optional: Implement logic to handle connection errors gracefully
})
redisInstance.on('connect', () => {
logger.info('Connected to Redis successfully.')
})
// Attempt to connect explicitly to catch initial errors early if desired
// redisInstance.connect().catch(err => logger.error({ err }, 'Initial Redis connection failed'));
} catch (error) {
logger.error({ error }, 'Failed to initialize Redis client')
// Depending on your app's needs, you might throw the error
// or return a mock/null client to prevent crashes
throw error // Re-throw to indicate critical failure
}
}
return redisInstance
}
// Function to generate OTP Redis key
export const getOtpRedisKey = (userId: number): string => {
return `otp:${userId}`
}
// OTP expiry time in seconds (e.g., 5 minutes)
export const OTP_EXPIRY_SECONDS = 300
2. Create Plivo Client Utility:
Similarly, create a utility for the Plivo client.
// api/src/lib/plivo.ts
import { PlivoClient } from 'plivo'
import { logger } from './logger'
let plivoClientInstance: PlivoClient | null = null
export const getPlivoClient = (): PlivoClient => {
if (!plivoClientInstance) {
if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) {
logger.error('Plivo Auth ID or Token missing in environment variables.')
throw new Error(
'Plivo credentials are not configured in environment variables.'
)
}
try {
plivoClientInstance = new PlivoClient(
process.env.PLIVO_AUTH_ID,
process.env.PLIVO_AUTH_TOKEN
)
logger.info('Plivo client initialized.')
} catch (error) {
logger.error({ error }, 'Failed to initialize Plivo client')
throw error
}
}
return plivoClientInstance
}
3. Generate GraphQL Schema Definition (SDL) and Service:
Use the Redwood generator to scaffold the GraphQL types and mutations, along with the service file.
yarn rw g sdl twoFactorAuth
This command creates api/src/graphql/twoFactorAuth.sdl.ts
and api/src/services/twoFactorAuth/twoFactorAuth.ts
.
4. Define GraphQL Mutations:
Modify the generated SDL file (api/src/graphql/twoFactorAuth.sdl.ts
) to define the mutations for requesting and verifying OTPs.
# api/src/graphql/twoFactorAuth.sdl.ts
export const schema = gql`
type MutationResult {
success: Boolean!
message: String
}
type Mutation {
""""""
Requests an OTP to be sent via SMS to the user's registered phone number.
Assumes user is already authenticated (e.g., via session).
Requires a functional Redwood Auth setup.
""""""
requestOtp: MutationResult! @requireAuth
""""""
Verifies the OTP provided by the user.
Assumes user is already authenticated.
Requires a functional Redwood Auth setup.
""""""
verifyOtp(otp: String!): MutationResult! @requireAuth
""""""
Enables 2FA for the user after successful verification (e.g., during setup).
Requires prior OTP verification within the same flow. (Logic handled in service)
Requires a functional Redwood Auth setup.
""""""
enableTwoFactorAuth: MutationResult! @requireAuth
}
`
@requireAuth
: Ensures only authenticated users can call these mutations. Make sure yourapi/src/lib/auth.ts
is configured correctly, providingcurrentUser
in thecontext
.
5. Implement Service Logic:
Now, implement the core logic in api/src/services/twoFactorAuth/twoFactorAuth.ts
.
// api/src/services/twoFactorAuth/twoFactorAuth.ts
import { requireAuth } from 'src/lib/auth' // Make sure requireAuth populates context.currentUser
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
import { getPlivoClient } from 'src/lib/plivo'
import { getRedisClient, getOtpRedisKey, OTP_EXPIRY_SECONDS } from 'src/lib/redis'
import { RedwoodGraphQLError } from '@redwoodjs/graphql-server'
// Helper to generate a 6-digit OTP
const generateOtp = (): string => {
return Math.floor(100000 + Math.random() * 900000).toString()
}
// Note: A functional Redwood Auth setup providing `context.currentUser` is a prerequisite for these resolvers.
export const twoFactorAuthResolvers = {
Mutation: {
requestOtp: async () => {
requireAuth() // Ensures user is logged in and context.currentUser is available
const userId = context.currentUser.id
const redis = getRedisClient()
const plivo = getPlivoClient()
try {
const user = await db.user.findUnique({ where: { id: userId } })
if (!user || !user.phoneNumber) {
throw new RedwoodGraphQLError(
'User or phone number not found. Cannot send OTP.'
)
}
// IMPORTANT: Implement rate limiting here for production!
// Check last request time in Redis for userId/phoneNumber and block if too frequent.
// This example does not include rate limiting code.
const otp = generateOtp()
const redisKey = getOtpRedisKey(userId)
// Store OTP in Redis with expiry
await redis.setex(redisKey, OTP_EXPIRY_SECONDS, otp)
logger.info(`OTP generated and stored in Redis for user ${userId}`)
// Send SMS via Plivo
if (!process.env.PLIVO_PHONE_NUMBER) {
throw new Error('PLIVO_PHONE_NUMBER environment variable is not set.')
}
const messageResponse = await plivo.messages.create(
process.env.PLIVO_PHONE_NUMBER, // Sender ID (Your Plivo number)
user.phoneNumber, // Destination (User's number in E.164)
`Your verification code is: ${otp}` // Message text
// Optional: Add more Plivo options like { log: true }
)
logger.info(
`OTP SMS sent via Plivo for user ${userId}. Message UUID: ${messageResponse.messageUuid[0]}`
)
return { success: true, message: 'OTP sent successfully.' }
} catch (error) {
logger.error({ error, userId }, 'Failed to request OTP')
// Provide a generic error to the client
throw new RedwoodGraphQLError(
'Failed to send OTP. Please try again later.'
)
}
},
verifyOtp: async (_root: unknown, { otp }: { otp: string }) => {
requireAuth() // Ensures user context
const userId = context.currentUser.id
const redis = getRedisClient()
try {
const redisKey = getOtpRedisKey(userId)
const storedOtp = await redis.get(redisKey)
if (!storedOtp) {
return { success: false, message: 'OTP expired or not found. Please request a new one.' }
}
// IMPORTANT: Implement brute-force protection here for production!
// Track failed attempts in Redis for redisKey. After N failures, invalidate the OTP (e.g., redis.del(redisKey)).
// This example does not include brute-force protection code.
if (storedOtp === otp) {
// OTP is correct
// CRITICAL: Delete the OTP immediately after successful verification to prevent reuse
await redis.del(redisKey)
// Optional: Set a flag indicating successful verification for this session/flow,
// useful if enableTwoFactorAuth needs to confirm recent verification.
// Example: await redis.setex(`otp_verified:${userId}`, 600, 'true'); // 10 min window
logger.info(`OTP verified successfully for user ${userId}`)
return { success: true, message: 'OTP verified successfully.' }
} else {
// Invalid OTP
logger.warn(`Invalid OTP attempt for user ${userId}`)
// Increment failed attempt counter in Redis here (for brute-force protection)
return { success: false, message: 'Invalid OTP provided.' }
}
} catch (error) {
logger.error({ error, userId }, 'Failed to verify OTP')
throw new RedwoodGraphQLError(
'Failed to verify OTP. Please try again later.'
)
}
},
enableTwoFactorAuth: async () => {
requireAuth(); // Ensures user context
const userId = context.currentUser.id;
const redis = getRedisClient(); // Needed only if checking verification flag
try {
// Optional but Recommended Security Check:
// Ensure OTP was successfully verified very recently before enabling.
// Requires setting a flag in Redis during verifyOtp success.
// const verificationFlagKey = `otp_verified:${userId}`;
// const isVerified = await redis.get(verificationFlagKey);
// if (!isVerified) {
// logger.warn(`User ${userId} attempted to enable 2FA without recent OTP verification.`);
// return { success: false, message: 'Please verify your phone number with an OTP first.' };
// }
// Update user status in the database
await db.user.update({
where: { id: userId },
data: { isTwoFactorEnabled: true },
});
// Optional: Clean up the verification flag from Redis if used
// await redis.del(verificationFlagKey);
logger.info(`2FA enabled successfully for user ${userId}`);
return { success: true, message: 'Two-Factor Authentication enabled.' };
} catch (error) {
logger.error({ error, userId }, 'Failed to enable 2FA');
throw new RedwoodGraphQLError('Failed to enable Two-Factor Authentication.');
}
},
},
}
// Merge resolvers - Ensure this structure matches your setup if you have multiple services.
// If this is the only service, you might export directly or use Redwood's default merge.
// Example: export const services = { twoFactorAuth: twoFactorAuthResolvers }
Key points in the service:
- Authentication:
requireAuth()
ensures only logged-in users access these functions.context.currentUser.id
retrieves the user ID (requires a functional Redwood Auth setup). - OTP Generation: A simple 6-digit random number is generated.
- Redis Storage:
redis.setex(key, expiry, otp)
stores the OTP with an expiration time (OTP_EXPIRY_SECONDS
). - Plivo SMS:
plivo.messages.create()
sends the SMS. Ensuresrc
(sender) anddst
(destination) numbers are in E.164 format. - Verification:
redis.get(key)
retrieves the stored OTP. It's compared against the user's input. - Security: Crucially, the OTP is deleted from Redis (
redis.del(key)
) immediately after successful verification to prevent reuse. - Error Handling:
try...catch
blocks handle potential errors from Redis or Plivo, logging them and returning user-friendly errors viaRedwoodGraphQLError
. - Enabling 2FA: The
enableTwoFactorAuth
mutation updates the user's record in the database. It's recommended to add a check here to ensureverifyOtp
was successful recently (e.g., by checking a temporary flag set in Redis duringverifyOtp
). - Missing Protections: Note that the example code does not include implementations for rate limiting (on
requestOtp
) or brute-force protection (onverifyOtp
), which are essential for production security.
5. Web Side Implementation (Pages & Components)
Now, let's create the user interface for the 2FA challenge.
1. Generate Page and Component:
# Page to display the OTP input form
yarn rw g page TwoFactorChallenge /2fa-challenge
# Optional: Component for the OTP form itself
yarn rw g component OtpInputForm
2. Define GraphQL Mutations for the Web:
Create a file to define the GraphQL mutations that the web side will use. Redwood's build process will use these definitions to generate types.
# web/src/graphql/mutations/twoFactorAuthMutations.gql
# Matches the operation name used in the OtpInputForm component
mutation RequestOtpMutation {
requestOtp {
success
message
}
}
# Matches the operation name used in the OtpInputForm component
mutation VerifyOtpMutation($otp: String!) {
verifyOtp(otp: $otp) {
success
message
}
}
# If you have a separate flow for enabling 2FA after first verification
mutation EnableTwoFactorAuthMutation {
enableTwoFactorAuth {
success
message
}
}
3. Implement the OTP Input Component (OtpInputForm
):
This component will contain the form elements.
// web/src/components/OtpInputForm/OtpInputForm.tsx
import { useState } from 'react'
import { Form, TextField, Submit, FieldError } from '@redwoodjs/forms'
import { useMutation, gql } from '@redwoodjs/web' // Import gql
import { toast } from '@redwoodjs/web/toast'
// import { navigate, routes } from '@redwoodjs/router' // Only needed if navigating from here
// Define the mutations using gql. Ensure the operation names match those in the .gql file
// Redwood's build process uses these definitions (especially from .gql files) to generate types,
// but we still pass the gql definition directly to useMutation here.
const REQUEST_OTP_MUTATION = gql`
mutation RequestOtpMutation { # Matches name in .gql file
requestOtp {
success
message
}
}
`
const VERIFY_OTP_MUTATION = gql`
mutation VerifyOtpMutation($otp: String!) { # Matches name in .gql file
verifyOtp(otp: $otp) {
success
message
}
}
`
interface OtpInputFormProps {
phoneNumberHint?: string;
onSuccess: () => void; // Callback on successful verification
}
const OtpInputForm = ({ phoneNumberHint, onSuccess }: OtpInputFormProps) => {
const [otp, setOtp] = useState('');
const [resendDisabled, setResendDisabled] = useState(false);
const [verifyOtp, { loading: verifyLoading, error: verifyError }] = useMutation(
VERIFY_OTP_MUTATION,
{
onCompleted: (data) => {
if (data.verifyOtp.success) {
toast.success('Verification Successful!');
onSuccess(); // Call the success handler (e.g., navigate to dashboard)
} else {
toast.error(data.verifyOtp.message || 'Invalid OTP.');
}
},
onError: (error) => {
toast.error(error.message || 'Verification failed. Please try again.');
},
}
);
const [requestOtp, { loading: requestLoading, error: requestError }] = useMutation(
REQUEST_OTP_MUTATION,
{
onCompleted: (data) => {
if (data.requestOtp.success) {
toast.success('New OTP sent.');
setResendDisabled(true);
// Re-enable resend after a delay (e.g., 60 seconds)
setTimeout(() => setResendDisabled(false), 60000);
} else {
toast.error(data.requestOtp.message || 'Failed to send OTP.');
}
},
onError: (error) => {
toast.error(error.message || 'Failed to request OTP.');
},
}
);
const onSubmit = (data: { otp: string }) => {
verifyOtp({ variables: { otp: data.otp } });
};
const handleResend = () => {
if (!resendDisabled && !requestLoading) {
requestOtp();
}
};
return (
<Form onSubmit={onSubmit} className=""space-y-4"">
<h2 className=""text-xl font-semibold"">Enter Verification Code</h2>
{phoneNumberHint && (
<p className=""text-sm text-gray-600"">
Enter the 6-digit code sent to your phone number ending in {phoneNumberHint}.
</p>
)}
<div>
<label htmlFor=""otp"" className=""block text-sm font-medium text-gray-700"">
Verification Code
</label>
<TextField
name=""otp""
id=""otp""
value={otp}
onChange={(e) => setOtp(e.target.value)}
maxLength={6}
className=""mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm""
validation={{
required: { value: true, message: 'OTP is required' },
pattern: {
value: /^\d{6}$/,
message: 'OTP must be 6 digits',
},
}}
/>
<FieldError name=""otp"" className=""mt-1 text-xs text-red-600"" />
{verifyError && <p className=""mt-1 text-xs text-red-600"">{verifyError.message}</p>}
</div>
<div className=""flex items-center justify-between"">
<Submit
disabled={verifyLoading || requestLoading}
className=""inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50""
>
{verifyLoading ? 'Verifying...' : 'Verify Code'}
</Submit>
<button
type=""button""
onClick={handleResend}
disabled={resendDisabled || requestLoading}
className=""text-sm font-medium text-indigo-600 hover:text-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed""
>
{requestLoading ? 'Sending...' : resendDisabled ? 'Resend Code (wait 60s)' : 'Resend Code'}
</button>
{requestError && <p className=""mt-1 text-xs text-red-600"">{requestError.message}</p>}
</div>
</Form>
);
};
export default OtpInputForm;
4. Implement the Challenge Page (TwoFactorChallengePage
):
This page will display the OtpInputForm
. You'll likely navigate to this page after a successful primary login if 2FA is enabled for the user.
// web/src/pages/TwoFactorChallengePage/TwoFactorChallengePage.tsx
import { MetaTags, useMutation, gql } from '@redwoodjs/web' // Added useMutation, gql if needed for initial request
import { navigate, routes } from '@redwoodjs/router'
import OtpInputForm from 'src/components/OtpInputForm/OtpInputForm'
// Import useAuth or obtain user details needed (like phone number hint)
import { useAuth } from 'src/auth' // Assuming you use Redwood's auth
import { toast } from '@redwoodjs/web/toast' // Added toast import
// import { useEffect } from 'react' // Add if using useEffect for initial request
// Define mutation if needed for initial request on page load
// const REQUEST_OTP_MUTATION = gql`
// mutation RequestOtpMutation {
// requestOtp {
// success
// message
// }
// }
// `
const TwoFactorChallengePage = () => {
// --- Fetch user details to display hint ---
// This is an EXAMPLE using Redwood's useAuth().
// Adapt this logic based on YOUR specific authentication setup
// to retrieve the logged-in user's phone number.
const { currentUser } = useAuth()
// Create a hint (e.g., last 4 digits) from the user's phone number
// Handle cases where the number might not be available
const phoneNumberHint = currentUser?.phoneNumber
? `****${currentUser.phoneNumber.slice(-4)}` // Example: Show last 4 digits
: 'your registered number'; // Fallback message
// If you are NOT using Redwood's built-in auth or currentUser isn't populated here,
// you will need a different way to get the phone number hint (e.g., pass it during navigation).
// const phoneNumberHint = '****1234'; // Remove this placeholder if using real logic
const handleSuccess = () => {
// Navigate to the main application area after successful 2FA
// Replace 'dashboard' with your actual target route name
toast.success('Login successful!'); // Provide feedback
navigate(routes.dashboard())
}
// Optional: Trigger initial OTP request when page loads?
// Usually, the requestOtp mutation should be triggered *before* navigating
// to this page (e.g., immediately after password verification).
// If not, you might need to call requestOtp here using useEffect and useMutation.
// Example (if needed):
// const [requestOtp] = useMutation(REQUEST_OTP_MUTATION);
// useEffect(() => {
// requestOtp();
// }, [requestOtp]);
return (
<>
<MetaTags title=""Two Factor Challenge"" description=""Enter your verification code"" />
<div className=""min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8"">
<div className=""sm:mx-auto sm:w-full sm:max-w-md"">
<h1 className=""mt-6 text-center text-3xl font-extrabold text-gray-900"">
Two-Factor Authentication
</h1>
</div>
<div className=""mt-8 sm:mx-auto sm:w-full sm:max-w-md"">
<div className=""bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"">
<OtpInputForm
phoneNumberHint={phoneNumberHint}
onSuccess={handleSuccess}
/>
</div>
</div>
</div>
</>
)
}
export default TwoFactorChallengePage
Integration Points:
- Login Flow: Modify your existing login logic (where you verify username/password). After successful primary authentication:
- Fetch the user's record, including
isTwoFactorEnabled
andphoneNumber
. - Check if
user.isTwoFactorEnabled
istrue
. - If true:
- Call the
requestOtp
mutation (API side). - On success, navigate the user to the
TwoFactorChallengePage
(/2fa-challenge
). You might pass thephoneNumberHint
as state during navigation if not easily accessible viauseAuth
on the challenge page.
- Call the
- If false: Proceed directly to the main application area (e.g., dashboard).
- Fetch the user's record, including
- 2FA Setup Flow (Enabling 2FA): This requires a separate UI, typically in user settings:
- User indicates they want to enable 2FA.
- Prompt for their phone number (if not already collected). Store it on the user record.
- Initiate phone verification: Call
requestOtp
. - Present an OTP input form (similar to
OtpInputForm
). - User enters OTP, call
verifyOtp
. - If
verifyOtp
succeeds: Call theenableTwoFactorAuth
mutation to setisTwoFactorEnabled = true
in the database. Confirm success to the user. - If
verifyOtp
fails: Show an error and allow retries/resend.
6. Security Considerations
- Rate Limiting: Crucial. Implement rate limiting on the
requestOtp
mutation (API side) to prevent SMS Pumping fraud and abuse. Use Redis to track request timestamps per user ID or phone number and block excessive requests within a time window (e.g., max 1 request per 60 seconds, max 5 requests per hour). The provided code lacks this. - OTP Expiry: Use a short expiry time for OTPs (e.g., 5 minutes), enforced by Redis
setex
. Clearly communicate the expiry time to the user. - Brute Force Protection: Crucial. Limit the number of
verifyOtp
attempts for a given OTP/user. Track failed attempts in Redis (e.g., increment a counter with TTL). After N (e.g., 5) failures, invalidate the current OTP (redis.del(key)
) and force the user to request a new one. Consider temporary account lockouts after repeated failures across multiple OTPs. The provided code lacks this. - Secure Credential Storage: Never hardcode API keys or tokens. Use environment variables (
.env
) and ensure.env
is in.gitignore
. Use your deployment platform's secret management. - Input Validation: Sanitize and validate all user inputs (phone numbers - E.164 format, OTP format - digits only, expected length) on both the client and server sides.
- Session Management: Ensure robust session management. The 2FA verification step must be securely tied to the authenticated user's session. Redwood's auth setup helps here.
- Phishing Awareness: Educate users never to share their OTPs. OTPs should only be entered on your legitimate website.
- Use E.164 Format: Consistently use the E.164 format (
+countrycodePhoneNumber
) for phone numbers when storing them and when interacting with the Plivo API.
7. Error Handling and Logging
- API Errors: The service code includes
try...catch
blocks. Log detailed errors (including Plivo/Redis errors) using Redwood'slogger
. Return generic, user-friendly errors viaRedwoodGraphQLError
to avoid leaking sensitive details. - Plivo Errors: Check Plivo's API response codes and error messages in their logs. Handle specific scenarios like insufficient funds, invalid number format, carrier filtering, or blocked numbers. Consult Plivo's documentation for error codes.
- Redis Errors: Handle potential Redis connection issues or command errors gracefully. Log errors. If Redis is temporarily unavailable, the 2FA flow will likely fail; ensure this is handled cleanly.
- Web Errors: Use
onError
callbacks inuseMutation
hooks to display user-friendly messages usingtoast
or other UI elements. Log unexpected client-side errors.
8. Testing
-
Unit Tests (API): Use Jest (built into Redwood) to test the service functions (
requestOtp
,verifyOtp
,enableTwoFactorAuth
). Mock the Plivo client (getPlivoClient
), Redis client (getRedisClient
), Prisma (db
), and authentication context (context.currentUser
,requireAuth
). Verify logic like OTP generation, Redis calls (setex
,get
,del
), Plivo calls (messages.create
), database updates, and return values under various success and failure conditions.// Example structure for api/src/services/twoFactorAuth/twoFactorAuth.test.ts import { twoFactorAuthResolvers } from './twoFactorAuth' // Adjust import based on export import { getPlivoClient } from 'src/lib/plivo' import { getRedisClient, OTP_EXPIRY_SECONDS } from 'src/lib/redis' import { db } from 'src/lib/db' import { requireAuth } from 'src/lib/auth' // Mock this // --- Mocks --- jest.mock('src/lib/plivo'); // ... rest of the test file structure
-
Integration Tests: Test the flow end-to-end, from the web component triggering the mutation, through the API service, interacting with (mocked) Plivo/Redis, and updating the database. Redwood's testing utilities can help here.
-
Web Component Tests: Test the
OtpInputForm
andTwoFactorChallengePage
components using Jest and Redwood's testing setup (@redwoodjs/testing/web
). Verify rendering, form input handling, validation, state changes,useMutation
calls, and callbacks (onSuccess
). MockuseAuth
,navigate
,toast
, anduseMutation
.