code examples
code examples
Implementing AWS SNS OTP for Two-Factor Authentication in RedwoodJS
A guide on integrating AWS SNS for SMS-based OTP 2FA within a RedwoodJS application using dbAuth.
Implementing AWS SNS OTP for Two-Factor Authentication in RedwoodJS
This guide provides a step-by-step walkthrough for integrating AWS Simple Notification Service (SNS) to send One-Time Passwords (OTPs) via SMS, adding a layer of Two-Factor Authentication (2FA) to a RedwoodJS application using the built-in dbAuth provider.
We'll build a system where users, after logging in with their password, must verify their identity using a code sent to their registered phone number if they have 2FA enabled.
Project Goals:
- Enhance application security with SMS-based OTP verification.
- Integrate AWS SNS for reliable SMS delivery.
- Leverage RedwoodJS's
dbAuthfor primary authentication. - Provide a seamless user experience for enabling and verifying 2FA.
Technology Stack:
- Framework: RedwoodJS (v8.x or later recommended)
- Authentication: RedwoodJS
dbAuth - Database: PostgreSQL (or SQLite/MySQL supported by Prisma)
- OTP Delivery: AWS Simple Notification Service (SNS)
- AWS Interaction: AWS SDK for JavaScript v3 (
@aws-sdk/client-sns) - Hashing: bcrypt (for secure OTP storage)
System Architecture:
+-------------+ +-----------------+ +-----------------+ +---------+ +-------------+
| User | ----> | Redwood Web UI | ----> | Redwood API | ----> | dbAuth | ----> | Database |
| (Browser) | | (React) | | (GraphQL/Lambda)| | (Login) | | (Prisma) |
+-------------+ +-----------------+ +-----------------+ +---------+ +-------------+
| | | ^ |
| 1. Login Request (Email/Pass) | | | |
| | | | 2. Verify Credentials |
| | | | |
| | | v 3. Check if 2FA Enabled |
| | |--------------------------------------> |
| | |
| | 4. If 2FA Enabled: |
| | a. Generate OTP |
| | b. Send OTP via AWS SNS |
| | c. Hash OTP & Store Hash/Expiry |
| |---------+ |
| | |
| v |
| +-----------------+ |
| | AWS SNS Service | |
| +-----------------+ |
| | |
| v 5. Send SMS OTP |
| +-----------------+ |
|<--------------------------------------------- | User's Phone | <---------------------+
| 6. User Receives OTP +-----------------+
|
| 7. User Submits OTP via Web UI
|
|------------> Redwood Web UI -------------> Redwood API (Verify OTP) -----> Database (Check OTP Hash/Expiry)
|
| 8. If Valid: Grant Full Access
| 9. If Invalid: Deny Access / RetryPrerequisites:
- Node.js (v18 or later) and Yarn installed.
- A RedwoodJS project already set up or willingness to create one.
- An AWS account with permissions to manage SNS and create IAM users.
- Access to the AWS Management Console.
- A mobile phone number capable of receiving SMS for testing.
<Callout type=""warn"" title=""Important: SNS Sandbox""> New AWS accounts start in the SNS Sandbox. This means SMS messages can only be sent to phone numbers verified within your AWS account until you request and are granted Production Access via the AWS Console (SNS -> SMS and Voice -> Production access requests). Plan for this verification step. </Callout>
Final Outcome:
A RedwoodJS application where users can optionally enable SMS-based 2FA. Authenticated users with 2FA enabled will be prompted for an OTP code after password login before gaining full access.
1. Setting up the Project
We'll start with a new RedwoodJS project and set up dbAuth. If you have an existing project with dbAuth, you can adapt these steps.
1.1 Create RedwoodJS App (if needed):
Open your terminal and run:
yarn create redwood-app ./redwood-sns-otp
cd redwood-sns-otp1.2 Setup dbAuth:
Redwood's dbAuth provides the foundation for user authentication (signup, login, password management).
yarn rw setup auth dbAuthThis command modifies your schema, adds API functions, and generates web-side pages for authentication.
1.3 Modify Database Schema:
We need to add fields to the User model to support 2FA. Open api/db/schema.prisma:
// api/db/schema.prisma
datasource db {
provider = "postgresql" // Or your chosen DB provider
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}
model User {
id Int @id @default(autoincrement())
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
roles String? @default("user") // Optional: For role-based access
// --- 2FA Fields ---
phoneNumber String? @unique // Store in E.164 format (e.g., +12223334444)
twoFactorEnabled Boolean @default(false)
otpSecret String? // Stores the securely HASHED OTP secret
otpExpiresAt DateTime? // Store expiry time for the OTP
otpAttempts Int? @default(0) // Track failed attempts
// --- End 2FA Fields ---
// Optional: Add other user profile fields as needed
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Explanation:
phoneNumber: Stores the user's verified phone number for OTP delivery (must be unique). Storing in E.164 format is crucial for SNS.twoFactorEnabled: A flag indicating if the user has enabled 2FA.otpSecret: Stores a secure hash (e.g., bcrypt) of the generated OTP. Never store the plain OTP.otpExpiresAt: Timestamp indicating when the generated OTP becomes invalid.otpAttempts: Tracks failed verification attempts to prevent brute-force attacks.
1.4 Apply Database Migrations:
Run the Prisma migration command to apply schema changes to your database:
yarn rw prisma migrate dev --name add-2fa-fieldsThis creates a new migration file and updates your database schema.
1.5 Install Dependencies:
Install the necessary AWS SDK v3 package for SNS and bcrypt for hashing:
yarn workspace api add @aws-sdk/client-sns bcrypt @types/bcrypt1.6 Configure Environment Variables:
Add AWS credentials and SNS configuration to your .env file. Never commit .env files with secrets to version control.
# .env (at the root of your project)
# Database URL (already present from Redwood setup)
DATABASE_URL="postgresql://user:password@localhost:5432/redwood_sns_otp?schema=public"
# --- AWS SNS Configuration ---
# Replace with your actual AWS credentials and region
AWS_ACCESS_KEY_ID="YOUR_AWS_ACCESS_KEY_ID"
AWS_SECRET_ACCESS_KEY="YOUR_AWS_SECRET_ACCESS_KEY"
AWS_REGION="us-east-1" # e.g., us-east-1, eu-west-2
AWS_SNS_SENDER_ID="MyBrandOTP" # Optional: A custom sender ID (requires registration in some regions)
# --- End AWS SNS Configuration ---
# Optional: Redwood Logger Level
LOG_LEVEL="info"Obtaining AWS Credentials:
- Go to the AWS Management Console -> IAM (Identity and Access Management).
- Navigate to Users and click Add users.
- Enter a username (e.g.,
redwood-sns-user). Select Access key - Programmatic access as the credential type. - Click Next: Permissions. Choose Attach existing policies directly.
- Search for and select the
AmazonSNSFullAccesspolicy (for simplicity). For production, create a custom policy with least privilege, granting onlysns:Publishpermissions. - Click Next: Tags (optional), Next: Review, then Create user.
- Crucially: Copy the Access key ID and Secret access key. You won't be able to see the secret key again after leaving this screen. Store them securely in your
.envfile. - Set
AWS_REGIONto the AWS region where you want to operate SNS (e.g.,us-east-1). AWS_SNS_SENDER_IDis optional but recommended for branding. Note that using custom Sender IDs often requires registration with authorities in specific countries (like India, USA 10DLC). If omitted or unregistered, SNS might use a generic number.
2. Implementing Core Functionality (OTP Logic)
We'll create services on the API side to handle OTP generation, sending, and verification.
2.1 Create OTP Service:
Generate a new service for OTP logic:
yarn rw g service otpThis creates api/src/services/otp/otp.ts, otp.scenarios.ts, and otp.test.ts.
2.2 Implement OTP Generation and Sending:
Open api/src/services/otp/otp.ts and add the logic.
// api/src/services/otp/otp.ts
import crypto from 'crypto' // Use Node's crypto module for secure random generation
import bcrypt from 'bcrypt'
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns'
import type { Prisma } from '@prisma/client'
import { db } from 'src/lib/db'
import { requireAuth } from 'src/lib/auth' // Use Redwood's auth checker
import { logger } from 'src/lib/logger' // Use Redwood's logger
const OTP_LENGTH = 6 // Standard OTP length
const OTP_VALIDITY_MINUTES = 5 // How long the OTP is valid
const MAX_OTP_ATTEMPTS = 5 // Max verification attempts
const BCRYPT_SALT_ROUNDS = 10 // Cost factor for bcrypt hashing
// Initialize SNS Client (ensure AWS env vars are set)
const snsClient = new SNSClient({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
})
/**
* Generates a cryptographically secure random numeric string using Node's crypto module.
*/
const generateOtpCode = (length: number): string => {
let otp = ''
for (let i = 0; i < length; i++) {
// crypto.randomInt is preferred over Math.random() for security-sensitive numbers
otp += crypto.randomInt(0, 10).toString()
}
return otp
}
/**
* Sends an OTP code via AWS SNS.
*/
const sendOtpSms = async (phoneNumber: string, otpCode: string) => {
// Ensure E.164 format for SNS
if (!phoneNumber || !phoneNumber.match(/^\+[1-9]\d{1,14}$/)) {
logger.error({ custom: { phoneNumber } }, 'Invalid or missing phone number for SNS. Must be E.164.')
throw new Error('Invalid phone number format. Must start with +countrycode.')
}
const brandName = process.env.AWS_SNS_SENDER_ID || 'YourApp' // Use sender ID or fallback
const message = `Your ${brandName} verification code is: ${otpCode}. It expires in ${OTP_VALIDITY_MINUTES} minutes.`
const params = {
Message: message,
PhoneNumber: phoneNumber,
// Optional: Add MessageAttributes for SenderID if registered and required
// MessageAttributes: {
// 'AWS.SNS.SMS.SenderID': {
// 'DataType': 'String',
// 'StringValue': process.env.AWS_SNS_SENDER_ID
// },
// 'AWS.SNS.SMS.SMSType': {
// 'DataType': 'String',
// 'StringValue': 'Transactional' // Or 'Promotional'
// }
// }
}
try {
logger.info({ custom: { phoneNumber } }, 'Attempting to send OTP via SNS')
const command = new PublishCommand(params)
const data = await snsClient.send(command)
logger.info({ custom: { messageId: data.MessageId, phoneNumber } }, 'Successfully sent OTP via SNS')
return data
} catch (error) {
logger.error({ err: error, custom: { phoneNumber } }, 'Failed to send OTP via SNS')
// Handle specific SNS errors if needed (e.g., throttling, invalid number)
throw new Error('Failed to send verification code. Please try again later.')
}
}
/**
* Mutation: Initiates the OTP request process for the logged-in user.
* Requires the user to have 2FA enabled and a phone number set.
*/
export const requestOtp = async () => {
requireAuth() // Ensure user is logged in
const userId = context.currentUser.id
const user = await db.user.findUnique({ where: { id: userId } })
if (!user) {
throw new Error('User not found.') // Should not happen if requireAuth passes
}
if (!user.twoFactorEnabled || !user.phoneNumber) {
throw new Error('Two-factor authentication is not enabled or phone number is missing.')
}
// --- Basic Rate Limiting Check (Example) ---
// Production systems *must* implement more robust IP-based and user-based rate limiting
// (e.g., using Redis with leaky/token bucket algorithms, or platform-specific middleware)
// to prevent SMS toll fraud and abuse. This example only checks expiry.
if (user.otpExpiresAt && user.otpExpiresAt > new Date()) {
const secondsRemaining = Math.ceil((user.otpExpiresAt.getTime() - Date.now()) / 1000);
logger.warn({ custom: { userId } }, `OTP request denied. Previous OTP still valid for ${secondsRemaining} seconds.`);
throw new Error(`Please wait ${secondsRemaining} seconds before requesting a new code.`);
}
// --- End Rate Limiting Check ---
const otpCode = generateOtpCode(OTP_LENGTH)
const expiresAt = new Date(Date.now() + OTP_VALIDITY_MINUTES * 60 * 1000)
try {
// 1. Send the plain OTP via SMS first
await sendOtpSms(user.phoneNumber, otpCode)
// 2. Hash the OTP for secure storage
const hashedOtp = await bcrypt.hash(otpCode, BCRYPT_SALT_ROUNDS)
// 3. Store the HASHED OTP and expiry in the database
await db.user.update({
where: { id: userId },
data: {
otpSecret: hashedOtp, // Store the hash, not the plain code
otpExpiresAt: expiresAt,
otpAttempts: 0, // Reset attempts on new code generation
},
})
logger.info({ custom: { userId } }, 'OTP requested successfully.')
// Return limited info to the client
return { success: true, message: `Verification code sent to ${user.phoneNumber.slice(0, -4)}****.` }
} catch (error) {
logger.error({ err: error, custom: { userId } }, 'Error during OTP request process.')
// Avoid clearing DB fields here, as the user might still need to retry.
// Let expiry handle cleanup eventually.
throw new Error('Failed to request verification code. Please try again.')
}
}
/**
* Mutation: Verifies the OTP code submitted by the user.
*/
interface VerifyOtpInput {
otpCode: string
}
export const verifyOtp = async ({ otpCode }: VerifyOtpInput) => {
requireAuth()
const userId = context.currentUser.id
if (!otpCode || otpCode.length !== OTP_LENGTH || !/^\d+$/.test(otpCode)) {
throw new Error('Invalid OTP format.');
}
const user = await db.user.findUnique({ where: { id: userId } })
if (!user || !user.twoFactorEnabled) {
throw new Error('User not found or 2FA not enabled.')
}
// otpSecret now holds the HASH
if (!user.otpSecret || !user.otpExpiresAt) {
throw new Error('No active OTP request found. Please request a new code.')
}
// --- Attempt Limiting ---
if (user.otpAttempts >= MAX_OTP_ATTEMPTS) {
logger.warn({ custom: { userId } }, 'Max OTP attempts exceeded.');
// Clear the current OTP to force requesting a new one after too many failures
await db.user.update({
where: { id: userId },
data: { otpSecret: null, otpExpiresAt: null, otpAttempts: null },
});
throw new Error('Maximum verification attempts exceeded. Please request a new code.');
}
// --- Check Expiry ---
if (new Date() > user.otpExpiresAt) {
logger.warn({ custom: { userId } }, 'OTP expired attempt.');
await db.user.update({
where: { id: userId },
data: {
otpSecret: null, // Clear expired OTP hash
otpExpiresAt: null,
otpAttempts: (user.otpAttempts || 0) + 1, // Still count as an attempt
},
});
throw new Error('Verification code has expired. Please request a new one.')
}
// --- Verify Code using bcrypt ---
const isValid = await bcrypt.compare(otpCode, user.otpSecret)
if (isValid) {
// Success! Clear OTP fields
await db.user.update({
where: { id: userId },
data: {
otpSecret: null,
otpExpiresAt: null,
otpAttempts: null, // Reset attempts on success
},
})
logger.info({ custom: { userId } }, 'OTP verified successfully.')
// Note: Redwood's default dbAuth session cookie isn't typically modified
// by this 2FA verification step. Application access control after login
// relies on the frontend flow (redirecting to OTP page, then away upon success).
// If persistent 2FA status within the session is needed, session handling
// would need customization (more advanced).
return { success: true, message: 'Verification successful.' }
} else {
// Invalid code
await db.user.update({
where: { id: userId },
data: {
otpAttempts: (user.otpAttempts || 0) + 1,
},
})
logger.warn({ custom: { userId, attempts: (user.otpAttempts || 0) + 1 } }, 'Invalid OTP code entered.')
throw new Error('Invalid verification code.')
}
}
// Add other potential functions like disableTwoFactorAuth if neededExplanation:
- Initialization:
SNSClientandbcryptare imported. Constants are defined. generateOtpCode: Uses Node'scrypto.randomIntfor stronger random number generation compared toMath.random().sendOtpSms:- Validates E.164 phone number format.
- Constructs and sends the SMS via
SNSClient. - Includes error handling and logging.
requestOtp(Mutation):- Authenticates user, checks 2FA status/phone number.
- Includes a basic expiry-based rate limit check. Emphasizes the critical need for robust IP/user-based rate limiting in production.
- Generates the plain
otpCode. - Sends the plain
otpCodevia SMS. - Hashes the
otpCodeusingbcrypt. - Stores the hashed OTP (
otpSecret) and expiry in the database, resetting attempts. - Returns a success message.
verifyOtp(Mutation):- Authenticates user, validates input format.
- Retrieves user and checks for an active OTP hash (
otpSecret). - Enforces
MAX_OTP_ATTEMPTS. - Checks for OTP expiry.
- Uses
bcrypt.compareto securely compare the submitted plainotpCodeagainst the storedotpSecrethash. - Clears OTP fields on success.
- Increments attempts on failure.
- Includes comments on session state implications.
3. Building the API Layer (GraphQL)
Define the GraphQL schema for the OTP mutations.
3.1 Update GraphQL Schema:
Open api/src/graphql/otp.sdl.ts and define the mutations and types:
// api/src/graphql/otp.sdl.ts
export const schema = gql`
type OtpResponse {
success: Boolean!
message: String
}
type Mutation {
"""
Requests an OTP code to be sent via SMS to the user's registered phone number.
Requires user to be authenticated and have 2FA enabled.
"""
requestOtp: OtpResponse! @requireAuth
"""
Verifies the OTP code submitted by the user against the stored hash.
Requires user to be authenticated.
"""
verifyOtp(otpCode: String!): OtpResponse! @requireAuth
}
`Explanation:
OtpResponse: A simple type to return success status and a message.requestOtp: Mutation to trigger the OTP sending process. Protected by@requireAuth.verifyOtp: Mutation to submit the OTP code for verification. TakesotpCodeas input. Protected by@requireAuth. Uses standard"""for docstrings.
3.2 Add Service to GraphQL Handler:
Ensure the otp service is included in your GraphQL handler. Redwood usually does this automatically via services import globbing in api/src/functions/graphql.ts, but double-check:
// api/src/functions/graphql.ts
// ... other imports
import services from 'src/services/**/*.{js,ts}' // Ensure this line includes your service
export const handler = createGraphQLHandler({
// ... other config
loggerConfig: { logger, options: {} },
directives,
sdls,
services, // Make sure 'services' is passed here
// ... onException
})4. Integrating Third-Party Services (AWS SNS)
This section was largely covered during setup and implementation. Key points:
- Configuration: AWS credentials (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY) andAWS_REGIONmust be in.env. - Secure Handling: Use environment variables; never hardcode credentials. Ensure your
.envfile is in.gitignore. In production, use secret management tools provided by your deployment platform (e.g., Vercel Environment Variables, Netlify Build Environment Variables, AWS Secrets Manager). - IAM Permissions: Use the principle of least privilege. The IAM user only needs
sns:Publishpermission for the specific SNS topic or phone numbers if possible, notAmazonSNSFullAccess, for production. - AWS Console:
- IAM:
AWS Console->IAM->Users->Add User/Manage Security Credentials. - SNS:
AWS Console->Simple Notification Service. Monitor usage and configure settings (like delivery status logging) here. Check SNS quotas and SMS pricing for your region.
- IAM:
- Sender ID: If using
AWS_SNS_SENDER_ID, be aware of registration requirements per country (e.g., US 10DLC, India). Failure to register may result in delivery issues or use of a generic number.
<Callout type=""warn"" title=""CRITICAL: SNS Sandbox"">
- By default, new AWS accounts operate SNS in a ""sandbox"" environment.
- Limitation: While in the sandbox, you can only send SMS messages to phone numbers that have been explicitly verified within your AWS account via the SNS console. Sending to unverified numbers will fail.
- Action Required: To send SMS to any valid phone number (i.e., your actual users), you must request Production Access for SNS through the AWS Management Console. Navigate to
Simple Notification Service->SMS and Voice->Production access requestsand submit the request form. This process involves review by AWS. Do this before deploying to real users. </Callout>
5. Error Handling, Logging, and Retries
- Error Handling: The
otp.tsservice includestry...catchblocks around database operations, hashing, and SNS calls. It throws user-friendly errors back to the client while logging detailed errors internally usingsrc/lib/logger. - Logging: Redwood's built-in logger (
src/lib/logger) is used. ConfigureLOG_LEVELin.env. Logs appear in the console (dev) and deployment platform's service (prod). Include context (userId) in logs. - Retries (SNS): AWS SDK v3 has built-in retries for transient network errors/throttling from SNS. Custom retry logic for the SDK call is usually unnecessary.
- Retries (User): The
requestOtplogic includes basic rate limiting. The "Resend Code" frontend feature handles user retries.verifyOtphandles invalid attempts and expiry.
6. Database Schema and Data Layer
Covered in Section 1.
- Schema:
api/db/schema.prismadefines theUsermodel with 2FA fields (otpSecretstores the hash). - Migrations:
yarn rw prisma migrate devapplies schema changes. - Data Access: Redwood services use Prisma Client (
src/lib/db). - Performance:
- Indexes on
@idand@uniquefields are automatic. - Consider separating OTP data (
otpSecret,otpExpiresAt,otpAttempts) to Redis/Memcached for very high-traffic apps to reduce User table contention (adds complexity). - Optimize queries if needed.
- Indexes on
7. Adding Security Features
- Input Validation:
- Phone numbers validated for E.164 format (
otp.ts). - OTP codes validated for length/numeric format (
otp.ts). - GraphQL layer provides type validation. Add custom service-level validation.
- Phone numbers validated for E.164 format (
- Authentication/Authorization:
@requireAuthprotects OTP mutations. - Rate Limiting: Absolutely critical for
requestOtp(prevents SMS cost abuse/toll fraud) andverifyOtp(prevents brute-force).- The example has minimal rate limiting.
- Production Requirement: Implement robust IP-based and/or user-based rate limiting. Use middleware (
express-rate-limitif applicable), platform features (Vercel/Netlify rate limiting), or external stores like Redis (leaky/token bucket algorithms).
- Brute Force Protection:
otpAttemptsfield andMAX_OTP_ATTEMPTSconstant inverifyOtp. Consider account locking or CAPTCHAs after excessive failures. - Secret Management: AWS keys via environment variables (Section 4).
- OTP Security:
- Hashing: Implemented using
bcryptto store OTP hashes (otpSecret) securely. Comparison usesbcrypt.compare. - Expiry: Short validity period (
OTP_VALIDITY_MINUTES). - Length: Standard 6-digit length.
- Secure Generation: Uses Node's
crypto.randomIntfor cryptographically stronger random numbers.
- Hashing: Implemented using
- CSRF Protection: Redwood includes CSRF protection; verify your setup.
- Session Security:
dbAuthprovides secure cookie-based sessions.
8. Frontend Implementation (Web Side)
We need pages/components to manage and verify 2FA.
8.1 Add Phone Number & Enable 2FA Component:
Create a component for users to manage their 2FA settings.
- Prerequisites: Accessible only to logged-in users.
- Functionality: Input phone number (E.164), enable/disable 2FA buttons.
Example Component Snippet (web/src/components/TwoFactorSettingsCell/TwoFactorSettingsCell.tsx):
// web/src/components/TwoFactorSettingsCell/TwoFactorSettingsCell.tsx
// Generate this with `yarn rw g cell TwoFactorSettings`
import type { FindTwoFactorSettingsQuery, FindTwoFactorSettingsQueryVariables } from 'types/graphql'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
import { Form, TextField, Submit, FieldError, Label, useForm } from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { useState } from 'react'
// GraphQL Query to fetch user's current 2FA status
// NOTE: Assumes you have a query named `getCurrentUserSettings` in your SDL/Service.
export const QUERY = gql`
query FindTwoFactorSettingsQuery {
twoFactorSettings: getCurrentUserSettings {
id
phoneNumber
twoFactorEnabled
}
}
`
// --- Mutations ---
// NOTE: Assumes you create these mutations in your SDL and Service (see example snippets below).
const ENABLE_2FA_MUTATION = gql`
mutation EnableTwoFactorAuthMutation($phoneNumber: String!) {
enableTwoFactorAuth(phoneNumber: $phoneNumber) {
success
message
user { # Return updated user data
id
phoneNumber
twoFactorEnabled
}
}
}
`
const DISABLE_2FA_MUTATION = gql`
mutation DisableTwoFactorAuthMutation {
disableTwoFactorAuth {
success
message
user {
id
phoneNumber
twoFactorEnabled
}
}
}
`
// --- End Mutations ---
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>User settings not found.</div>
export const Failure = ({ error }: CellFailureProps<FindTwoFactorSettingsQueryVariables>) => (
<div style={{ color: 'red' }}>Error: {error?.message}</div>
)
export const Success = ({ twoFactorSettings }: CellSuccessProps<FindTwoFactorSettingsQuery, FindTwoFactorSettingsQueryVariables>) => {
const [phoneNumber, setPhoneNumber] = useState(twoFactorSettings.phoneNumber || '')
const formMethods = useForm()
const [enable2FA, { loading: enableLoading }] = useMutation(ENABLE_2FA_MUTATION, {
onCompleted: (data) => {
if (data.enableTwoFactorAuth.success) {
toast.success(data.enableTwoFactorAuth.message || '2FA Enabled!')
setPhoneNumber(data.enableTwoFactorAuth.user.phoneNumber) // Update local state
// QUERY refetch will update the cell state automatically
} else {
toast.error(data.enableTwoFactorAuth.message || 'Failed to enable 2FA.')
}
},
onError: (error) => {
toast.error(error.message)
},
// Refetch the user settings after mutation
refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true,
})
const [disable2FA, { loading: disableLoading }] = useMutation(DISABLE_2FA_MUTATION, {
onCompleted: (data) => {
if (data.disableTwoFactorAuth.success) {
toast.success(data.disableTwoFactorAuth.message || '2FA Disabled!')
setPhoneNumber('') // Clear local state
} else {
toast.error(data.disableTwoFactorAuth.message || 'Failed to disable 2FA.')
}
},
onError: (error) => {
toast.error(error.message)
},
refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true,
})
const onEnableSubmit = (data) => {
// Basic client-side format check (more robust validation server-side)
if (!data.phoneNumber || !data.phoneNumber.match(/^\+[1-9]\d{1,14}$/)) {
toast.error('Please enter a valid phone number in E.164 format (e.g., +15551234567).')
return;
}
enable2FA({ variables: { phoneNumber: data.phoneNumber } })
}
const onDisableClick = () => {
if(confirm('Are you sure you want to disable Two-Factor Authentication?')) {
disable2FA()
}
}
return (
<div>
<h2>Two-Factor Authentication (2FA)</h2>
{twoFactorSettings.twoFactorEnabled ? (
<div>
<p>Status: <strong style={{color: 'green'}}>Enabled</strong></p>
<p>Registered Phone: {twoFactorSettings.phoneNumber}</p>
<button onClick={onDisableClick} disabled={disableLoading}>
{disableLoading ? 'Disabling...' : 'Disable 2FA'}
</button>
</div>
) : (
<div>
<p>Status: <strong style={{color: 'red'}}>Disabled</strong></p>
<p>Enable 2FA by adding your phone number below. A verification code will be sent via SMS for login confirmation.</p>
<Form onSubmit={onEnableSubmit} formMethods={formMethods}>
<Label name=""phoneNumber"" errorClassName=""error"">Phone Number (E.164 format: +1...)</Label>
<TextField
name=""phoneNumber""
placeholder=""+15551234567""
validation={{ required: true, pattern: { value: /^\+[1-9]\d{1,14}$/, message: ""Invalid E.164 format""} }}
errorClassName=""error""
defaultValue={phoneNumber} // Use state for controlled component if preferred
/>
<FieldError name=""phoneNumber"" className=""error"" />
<Submit disabled={enableLoading}>
{enableLoading ? 'Enabling...' : 'Enable 2FA'}
</Submit>
</Form>
</div>
)}
</div>
)
}
// --- IMPORTANT: Backend Implementation Required ---
/*
The above frontend component relies on GraphQL queries and mutations
(`getCurrentUserSettings`, `enableTwoFactorAuth`, `disableTwoFactorAuth`)
that you need to implement on the backend (API side).
Follow the same pattern used for the OTP service/SDL. Here are example snippets:
// api/src/graphql/users.sdl.ts (oFrequently Asked Questions
How to set up two-factor authentication in RedwoodJS?
Set up 2FA in RedwoodJS by first installing required dependencies like the AWS SDK for SNS and bcrypt. Then, modify your Prisma schema to include fields for phone numbers, 2FA status, OTP details, and update your database. Finally, configure environment variables for your AWS credentials and region in your .env file. Remember to apply database migrations after schema changes.
What is AWS SNS used for in RedwoodJS 2FA?
AWS SNS (Simple Notification Service) is used to deliver One-Time Passwords (OTPs) via SMS messages to users' registered phone numbers as part of the two-factor authentication process. This ensures secure access by verifying user identity.
Why does my RedwoodJS app need bcrypt for 2FA?
bcrypt is essential for securely storing OTPs. It hashes the generated OTP codes before saving them to the database, preventing storage of sensitive information in plain text. This protects user accounts in case of a data breach.
When should I request production access for AWS SNS?
Request production access for AWS SNS before deploying your RedwoodJS application to real users. New AWS accounts default to a sandbox environment, restricting SMS messages to verified numbers within your account. Production access enables you to send OTPs to any valid phone number.
Can I customize the SMS sender ID in RedwoodJS 2FA?
Yes, you can customize the SMS sender ID using the AWS_SNS_SENDER_ID environment variable in your RedwoodJS app. However, using a custom sender ID often requires registering with authorities in specific countries to ensure compliance. If omitted, a generic number may be used.
How to generate an OTP code securely in RedwoodJS?
Use Node.js's `crypto.randomInt` function within your RedwoodJS OTP service to generate cryptographically secure random numbers for your OTP codes. This method is preferred over `Math.random()` for enhanced security in sensitive operations.
What is the role of dbAuth in RedwoodJS two-factor authentication?
dbAuth in RedwoodJS provides the primary authentication layer (username/password login). The OTP-based 2FA adds an additional security layer on top of dbAuth, triggered only after successful password authentication, if the user has 2FA enabled.
How to implement rate limiting for OTP requests in RedwoodJS?
Implement robust rate limiting for OTP requests in RedwoodJS by integrating mechanisms like IP-based or user-based limits. You can utilize middleware or external stores like Redis with algorithms such as leaky bucket or token bucket, preventing abuse and ensuring the security of the OTP process.
What is the recommended phone number format for RedwoodJS 2FA?
Use the E.164 format (+15551234567) for storing and validating phone numbers in RedwoodJS 2FA. This international standard format is crucial for compatibility with AWS SNS and ensures accurate OTP delivery.
How to verify OTP codes securely in RedwoodJS?
Verify OTP codes securely using `bcrypt.compare` to check the user-submitted code against the stored bcrypt hash of the OTP. This prevents direct comparison of plain text codes and protects against security vulnerabilities.
What happens if a user exceeds maximum OTP attempts in RedwoodJS?
If a user exceeds the maximum OTP attempts (as defined by MAX_OTP_ATTEMPTS in your OTP service), their current OTP is cleared, requiring them to request a new one. This mitigates brute-force attacks.
Why is hashing important for OTP security in RedwoodJS?
Hashing is crucial for OTP security as it transforms the OTP into a unique, irreversible representation, preventing the storage of the actual OTP in the database. Using bcrypt for hashing adds a layer of protection against potential data breaches, ensuring that even if compromised, the OTPs remain unreadable.
How to handle OTP expiry in a RedwoodJS application?
Handle OTP expiry in RedwoodJS by setting an expiration time (`otpExpiresAt`) when generating the OTP. During verification, check if the current time is past the expiration; if so, invalidate the OTP, clear relevant fields, and prompt the user to request a new one.