Two-factor authentication (2FA) adds a critical layer of security to user logins, significantly reducing the risk of unauthorized access. One common and user-friendly 2FA method is One-Time Passwords (OTP) delivered via SMS or voice call.
This guide provides a step-by-step walkthrough for integrating Sinch's Verification API into a Node.js and Express application to implement robust OTP-based 2FA. We will build a system where users register, log in, and then verify their identity using an OTP sent to their phone number via Sinch before gaining full access.
Project Overview and Goals
What We'll Build:
- A Node.js/Express backend application.
- User registration with phone number capture.
- A login process requiring standard credentials (e.g., email/password).
- A second factor verification step using OTP sent via Sinch SMS (or potentially Voice).
- API endpoints to handle registration, login, OTP request, and OTP verification.
Problem Solved:
This implementation addresses the security vulnerability of relying solely on passwords for authentication by adding a possession factor (the user's phone) to the verification process.
Technologies Used:
- Node.js: Runtime environment for JavaScript backend development.
- Express: Minimalist web framework for Node.js, simplifying API creation.
- Sinch Verification API: A service to send and verify OTP codes via SMS and Voice, handling the complexities of global delivery and code generation.
- PostgreSQL (with Sequelize): Relational database and ORM for persistent user data storage. (Can be adapted for other databases).
- bcrypt: Library for securely hashing passwords.
- dotenv: Module to load environment variables from a
.env
file. - express-validator: Middleware for input validation.
- Helmet: Middleware for securing Express apps by setting various HTTP headers.
- express-rate-limit: Middleware for basic rate limiting to prevent brute-force attacks.
- axios: Promise-based HTTP client for making requests to the Sinch API.
- libphonenumber-js: Library for parsing, validating, and formatting phone numbers.
- pino: Fast, low-overhead JSON logger.
Architecture Diagram:
graph LR
A[User Browser/Client] -- HTTP Request --> B(Node.js/Express API);
B -- Register/Login Data --> C{Database (PostgreSQL)};
B -- Request OTP --> D(Sinch Verification API);
D -- Send OTP --> E[User's Phone (SMS/Voice)];
A -- Submit OTP --> B;
B -- Verify OTP --> D;
D -- Verification Result --> B;
B -- Update User Status/Session --> C;
B -- Success/Failure Response --> A;
style B fill:#f9f,stroke:#333,stroke-width:2px
style D fill:#ccf,stroke:#333,stroke-width:2px
style C fill:#9cf,stroke:#333,stroke-width:2px
Prerequisites:
- Node.js and npm (or yarn) installed.
- PostgreSQL server running locally or accessible. You must create the database specified in your
.env
file before running the application or migrations. - A Sinch account with API credentials (App Key and App Secret).
- Basic understanding of JavaScript, Node.js, Express, REST APIs, and SQL.
- A code editor (e.g., VS Code).
- A tool for testing APIs (e.g., Postman, curl).
Final Outcome:
By the end of this guide, you will have a functional backend API capable of registering users, logging them in, and requiring a Sinch-powered OTP verification step for enhanced security. You will also have a foundational structure for error handling, security, logging, and database management.
Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
Step 1: Create Project Directory and Initialize
Open your terminal and run the following commands:
mkdir node-sinch-otp-2fa
cd node-sinch-otp-2fa
npm init -y
This creates a new directory, navigates into it, and initializes a package.json
file with default settings.
Step 2: Install Dependencies
Install the core dependencies for Express, database interaction, security, environment variables, HTTP requests, logging, and phone number validation:
npm install express pg sequelize bcrypt dotenv express-validator helmet express-rate-limit axios pino libphonenumber-js
express
: The web framework.pg
: PostgreSQL client for Node.js (used by Sequelize).sequelize
: ORM for Node.js.bcrypt
: Password hashing library.dotenv
: Loads environment variables from.env
.express-validator
: Input validation middleware.helmet
: Security header middleware.express-rate-limit
: Request rate limiting middleware.axios
: HTTP client for Sinch API calls.pino
: Logger.libphonenumber-js
: Phone number validation/formatting.
Step 3: Install Development Dependencies
Install nodemon
for automatic server restarts, sequelize-cli
for migrations, and pino-pretty
for human-readable logs during development:
npm install --save-dev nodemon sequelize-cli pino-pretty
Step 4: Configure package.json
Scripts
Add scripts to your package.json
for starting the server and running migrations:
{
""name"": ""node-sinch-otp-2fa"",
""version"": ""1.0.0"",
""description"": """",
""main"": ""src/index.js"",
""scripts"": {
""start"": ""node src/index.js"",
""dev"": ""nodemon src/index.js | pino-pretty"",
""db:migrate"": ""npx sequelize-cli db:migrate --config src/config/config.json --migrations-path src/database/migrations --seeders-path src/database/seeders"",
""db:migrate:undo"": ""npx sequelize-cli db:migrate:undo --config src/config/config.json --migrations-path src/database/migrations --seeders-path src/database/seeders"",
""db:model:create"": ""npx sequelize-cli model:generate --config src/config/config.json --migrations-path src/database/migrations --models-path src/database/models"",
""test"": ""echo \""Error: no test specified\"" && exit 1""
},
""keywords"": [],
""author"": """",
""license"": ""ISC"",
""dependencies"": {
""axios"": ""^..."",
""bcrypt"": ""^..."",
""dotenv"": ""^..."",
""express"": ""^..."",
""express-rate-limit"": ""^..."",
""express-validator"": ""^..."",
""helmet"": ""^..."",
""libphonenumber-js"": ""^..."",
""pg"": ""^..."",
""pino"": ""^..."",
""sequelize"": ""^...""
},
""devDependencies"": {
""nodemon"": ""^..."",
""pino-pretty"": ""^..."",
""sequelize-cli"": ""^...""
}
}
Note: Replace ^...
with the actual versions installed. Ensure ""main""
points to your entry file.
Step 5: Create Project Structure
Organize your project with the following folder structure:
node-sinch-otp-2fa/
├── node_modules/
├── src/
│ ├── config/ # Configuration files (DB, etc.)
│ ├── controllers/ # Request handlers
│ ├── database/ # Migrations, seeders, models
│ │ ├── migrations/
│ │ ├── models/
│ │ └── seeders/ # Optional: for sample data
│ ├── middleware/ # Custom middleware (auth, validation, error)
│ ├── routes/ # API route definitions
│ ├── services/ # Business logic
│ ├── utils/ # Utility functions (logging, etc.)
│ └── index.js # Main application entry point
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore # Files/folders to ignore in Git
├── package-lock.json
└── package.json
Create these directories:
mkdir -p src/config src/controllers src/database/migrations src/database/models src/database/seeders src/middleware src/routes src/services src/utils
touch src/index.js .env .gitignore src/utils/logger.js
Step 6: Configure .gitignore
Add node_modules
, .env
, and log files to your .gitignore
file:
# .gitignore
node_modules/
.env
*.log
Step 7: Set Up Environment Variables (.env
)
Create the .env
file in the project root and add initial configuration. We will add Sinch keys later.
# .env
# Server Configuration
NODE_ENV=development
PORT=3000
BASE_URL=http://localhost:3000
LOG_LEVEL=debug # Use 'info' or 'warn' in production
# Database Configuration (Update with your credentials)
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_postgres_user
DB_PASSWORD=your_postgres_password
DB_NAME=sinch_otp_db
DB_DIALECT=postgres
# Security
PASSWORD_SALT_ROUNDS=10 # bcrypt salt rounds
# JWT_SECRET=your_super_secret_jwt_key_here # Uncomment and set when implementing JWT
# Rate Limiting (Example: 100 requests per 15 minutes per IP)
RATE_LIMIT_WINDOW_MS=900000 # 15 * 60 * 1000
RATE_LIMIT_MAX_REQUESTS=100
# Sinch API Credentials (Add these later)
# SINCH_APP_KEY=
# SINCH_APP_SECRET=
# SINCH_VERIFICATION_METHOD=sms # or 'callout' for voice
Important: Replace your_postgres_user
and your_postgres_password
with your actual PostgreSQL credentials. Crucially, you must manually create the database named sinch_otp_db
(or whatever you set DB_NAME
to) in your PostgreSQL instance before proceeding. Tools like createdb sinch_otp_db
(if psql CLI is installed) or a GUI like pgAdmin can be used.
Step 8: Configure Logger (src/utils/logger.js
)
Set up the Pino logger.
// src/utils/logger.js
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
// Use default JSON output in production, pretty print in development via package.json script
});
module.exports = logger;
Step 9: Basic Server Setup (src/index.js
)
Create a minimal Express server using the logger.
// src/index.js
require('dotenv').config(); // Load .env variables first
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const logger = require('./utils/logger'); // Import logger
const mainRouter = require('./routes'); // Assuming routes/index.js exists
const errorHandler = require('./middleware/errorHandler'); // Assuming middleware/errorHandler.js exists
const { sequelize } = require('./database/models'); // Import sequelize instance
const app = express();
const port = process.env.PORT || 3000;
// --- Middleware ---
// Security Headers
app.use(helmet());
// Rate Limiting
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10),
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10),
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: 'Too many requests from this IP, please try again after 15 minutes',
});
app.use(limiter);
// Body Parsers
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies
// --- Routes ---
app.get('/health', (req, res) => res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }));
app.use('/api/v1', mainRouter); // Mount main router under /api/v1
// --- Error Handling ---
app.use(errorHandler); // Central error handler
// --- Server Startup with DB Connection ---
async function startServer() {
try {
await sequelize.authenticate();
logger.info('Database connection established successfully.');
// Optional: Sync models during development (Use migrations in production)
// if (process.env.NODE_ENV === 'development') {
// await sequelize.sync({ alter: true }); // Be careful with alter: true
// logger.info('Models synchronized with database.');
// }
app.listen(port, () => {
logger.info(`Server running on ${process.env.BASE_URL}`);
logger.info(`Current environment: ${process.env.NODE_ENV}`);
});
} catch (error) {
logger.error('Unable to connect to the database:', error);
process.exit(1); // Exit if DB connection fails
}
}
startServer(); // Call the async function
module.exports = app; // Export for potential testing
Step 10: Set Up Main Router (src/routes/index.js
)
Create a placeholder main router.
// src/routes/index.js
const express = require('express');
const authRouter = require('./auth.routes'); // We will create this
const logger = require('../utils/logger');
const router = express.Router();
router.use('/auth', authRouter);
// Add other resource routers here (e.g., router.use('/users', userRouter);)
// Catch-all for undefined API routes within /api/v1
router.use('*', (req, res) => {
logger.warn(`404 Not Found: ${req.method} ${req.originalUrl}`);
res.status(404).json({ message: 'API endpoint not found' });
});
module.exports = router;
Step 11: Set Up Placeholder Auth Routes (src/routes/auth.routes.js
)
// src/routes/auth.routes.js
const express = require('express');
// const authController = require('../controllers/auth.controller'); // We'll create this
const router = express.Router();
// Placeholder routes - implementation comes later
router.post('/register', (req, res) => res.status(501).json({ message: 'Register endpoint not implemented yet' }));
router.post('/login', (req, res) => res.status(501).json({ message: 'Login endpoint not implemented yet' }));
router.post('/request-otp', (req, res) => res.status(501).json({ message: 'Request OTP endpoint not implemented yet' }));
router.post('/verify-otp', (req, res) => res.status(501).json({ message: 'Verify OTP endpoint not implemented yet' }));
module.exports = router;
Step 12: Basic Error Handler (src/middleware/errorHandler.js
)
Create a simple centralized error handler using the logger.
// src/middleware/errorHandler.js
const logger = require('../utils/logger');
const errorHandler = (err, req, res, next) => {
// Log the full error for debugging, including request info if helpful
logger.error({
message: err.message,
stack: err.stack,
statusCode: err.statusCode,
url: req.originalUrl,
method: req.method,
ip: req.ip,
}, 'Unhandled Error');
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
// Send a generic error response to the client
res.status(statusCode).json({
status: 'error',
statusCode,
message: process.env.NODE_ENV === 'production' && statusCode === 500
? 'An unexpected error occurred' // Don't leak details in production
: message,
});
};
module.exports = errorHandler;
Step 13: Run the Server
Start the development server:
npm run dev
You should see log output like Server running on http://localhost:3000
in your console. Test the health check endpoint: http://localhost:3000/health
. Accessing other /api/v1
routes should return 404 or 501.
Implementing Core Functionality (User Registration & Login)
Now, let's build the basic user registration and login functionality, including password hashing.
Step 1: Set Up Sequelize
Initialize Sequelize configuration:
# If not already in project root, cd node-sinch-otp-2fa
npx sequelize-cli init
This creates config/config.json
, models/index.js
, migrations/
, seeders/
. Move these into the src/database
directory structure we created earlier, adjusting paths as needed. Or, configure paths using a .sequelizerc
file in the project root:
// .sequelizerc
const path = require('path');
module.exports = {
'config': path.resolve('src', 'config', 'config.json'),
'models-path': path.resolve('src', 'database', 'models'),
'seeders-path': path.resolve('src', 'database', 'seeders'),
'migrations-path': path.resolve('src', 'database', 'migrations')
};
Now, re-run npx sequelize-cli init
if you created .sequelizerc
first, or manually move the generated files.
Modify src/config/config.json
to use environment variables:
// src/config/config.json
require('dotenv').config({ path: '../../.env' }); // Load .env from root
module.exports = {
""development"": {
""username"": process.env.DB_USER,
""password"": process.env.DB_PASSWORD,
""database"": process.env.DB_NAME,
""host"": process.env.DB_HOST,
""port"": process.env.DB_PORT,
""dialect"": process.env.DB_DIALECT
},
""test"": {
""username"": process.env.DB_USER,
""password"": process.env.DB_PASSWORD,
""database"": `${process.env.DB_NAME}_test`,
""host"": process.env.DB_HOST,
""port"": process.env.DB_PORT,
""dialect"": process.env.DB_DIALECT,
""logging"": false
},
""production"": {
""username"": process.env.DB_USER_PROD || process.env.DB_USER,
""password"": process.env.DB_PASSWORD_PROD || process.env.DB_PASSWORD,
""database"": process.env.DB_NAME_PROD || process.env.DB_NAME,
""host"": process.env.DB_HOST_PROD || process.env.DB_HOST,
""port"": process.env.DB_PORT_PROD || process.env.DB_PORT,
""dialect"": process.env.DB_DIALECT,
""logging"": false
}
}
Note: The logging
function using Pino was removed from the JSON as functions are not standard JSON. Logging can be configured when initializing Sequelize if needed.
Step 2: Create User Model and Migration
Generate the User model and its corresponding migration file (using the script from package.json
):
npm run db:model:create -- --name User --attributes email:string,password:string,phoneNumber:string,isVerified:boolean,sinchVerificationId:string,otpSecret:string,lastOtpSentAt:date
Edit the generated migration file in src/database/migrations/
. Ensure fields are correctly configured.
// Example: src/database/migrations/YYYYMMDDHHMMSS-create-user.js
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('Users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
email: {
type: Sequelize.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true,
}
},
password: {
type: Sequelize.STRING,
allowNull: false
},
phoneNumber: {
type: Sequelize.STRING, // Store in E.164 format
allowNull: false,
unique: true
},
isVerified: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false // Users start as unverified
},
sinchVerificationId: { // Store temp ID from Sinch verify request
type: Sequelize.STRING,
allowNull: true
},
otpSecret: { // For potential TOTP authenticator app support
type: Sequelize.STRING,
allowNull: true
},
lastOtpSentAt: { // For rate limiting OTP requests
type: Sequelize.DATE,
allowNull: true
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
// Add indexes for frequently queried columns
await queryInterface.addIndex('Users', ['email']);
await queryInterface.addIndex('Users', ['phoneNumber']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('Users');
}
};
Edit the corresponding model file src/database/models/user.js
to add instance methods and hooks.
// src/database/models/user.js
'use strict';
const { Model } = require('sequelize');
const bcrypt = require('bcrypt');
const logger = require('../../utils/logger'); // Use logger for hook errors
module.exports = (sequelize, DataTypes) => {
class User extends Model {
// Instance method to compare password
async isValidPassword(password) {
return bcrypt.compare(password, this.password);
}
static associate(models) {
// define association here if needed
}
}
User.init({
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true,
}
},
password: {
type: DataTypes.STRING,
allowNull: false
},
phoneNumber: {
type: DataTypes.STRING,
allowNull: false,
unique: true
// Validation using libphonenumber-js should happen before saving
},
isVerified: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
sinchVerificationId: {
type: DataTypes.STRING,
allowNull: true
},
otpSecret: {
type: DataTypes.STRING,
allowNull: true
},
lastOtpSentAt: {
type: DataTypes.DATE,
allowNull: true
}
}, {
sequelize,
modelName: 'User',
hooks: {
// Hash password before saving user
beforeCreate: async (user, options) => {
if (user.password) {
try {
const saltRounds = parseInt(process.env.PASSWORD_SALT_ROUNDS, 10);
user.password = await bcrypt.hash(user.password, saltRounds);
} catch (error) {
logger.error('Error hashing password during beforeCreate hook', error);
throw error; // Prevent user creation if hashing fails
}
}
},
// Optionally hash password on update if it changed
beforeUpdate: async (user, options) => {
if (user.changed('password')) {
try {
const saltRounds = parseInt(process.env.PASSWORD_SALT_ROUNDS, 10);
user.password = await bcrypt.hash(user.password, saltRounds);
} catch (error) {
logger.error('Error hashing password during beforeUpdate hook', error);
throw error; // Prevent update if hashing fails
}
}
}
}
});
return User;
};
Step 3: Run the Migration
Apply the migration to create the Users
table in your database:
npm run db:migrate
Verify the table creation in your PostgreSQL database.
Step 4: Database Connection in index.js
(Already Done)
We already modified src/index.js
in Step 9 of Section 1 to connect the database before starting the server.
Step 5: Implement Auth Service (src/services/auth.service.js
)
Create the service layer using the logger.
// src/services/auth.service.js
const { User } = require('../database/models');
const { Op } = require('sequelize');
const logger = require('../utils/logger');
const sinchService = require('./sinch.service'); // Assuming sinch.service.js exists or will be created
const { sequelize } = require('../database/models'); // For transactions
const { parsePhoneNumberFromString } = require('libphonenumber-js'); // For validation
class AuthService {
async registerUser({ email, password, phoneNumber }) {
try {
// Input validation (basic checks, more specific validation in middleware)
if (!email || !password || !phoneNumber) {
throw new Error('Email, password, and phone number are required.');
}
// Validate and format phone number using libphonenumber-js
const parsedNumber = parsePhoneNumberFromString(phoneNumber);
if (!parsedNumber || !parsedNumber.isValid()) {
const error = new Error('Invalid phone number format.');
error.statusCode = 400;
throw error;
}
const formattedPhoneNumber = parsedNumber.format('E.164'); // Store consistently
// Check if user already exists (email or formatted phone number)
const existingUser = await User.findOne({
where: {
[Op.or]: [{ email }, { phoneNumber: formattedPhoneNumber }]
}
});
if (existingUser) {
const field = existingUser.email === email ? 'Email' : 'Phone number';
const error = new Error(`${field} is already registered.`);
error.statusCode = 409; // Conflict
throw error;
}
// Create user (password hashing is handled by the model hook)
const newUser = await User.create({ email, password, phoneNumber: formattedPhoneNumber });
// Don't return sensitive info
const userJson = newUser.toJSON();
delete userJson.password;
delete userJson.sinchVerificationId;
delete userJson.otpSecret;
logger.info(`User registered successfully: ${userJson.email} (ID: ${userJson.id})`);
return userJson;
} catch (error) {
logger.error(`Error registering user ${email}:`, error);
// Ensure statusCode is set if not already
if (!error.statusCode) error.statusCode = 500;
throw error; // Re-throw for the controller/error handler
}
}
async loginUser({ email, password }) {
try {
const user = await User.findOne({ where: { email } });
if (!user) {
const error = new Error('Invalid email or password.');
error.statusCode = 401; // Unauthorized
throw error;
}
const isPasswordValid = await user.isValidPassword(password);
if (!isPasswordValid) {
const error = new Error('Invalid email or password.');
error.statusCode = 401;
throw error;
}
// Don't return sensitive info
const userJson = user.toJSON();
delete userJson.password;
delete userJson.sinchVerificationId;
delete userJson.otpSecret;
logger.info(`User login successful (first factor): ${user.email} (ID: ${user.id})`);
return userJson;
} catch (error) {
logger.error(`Error logging in user ${email}:`, error);
if (!error.statusCode) error.statusCode = 500;
throw error;
}
}
// --- OTP related methods ---
async requestOtpForUser(identifier) {
const findCondition = identifier.email
? { email: identifier.email }
: { phoneNumber: identifier.phoneNumber }; // Assumes E.164 format if phone is used
const user = await User.findOne({ where: findCondition });
if (!user) {
const error = new Error('User not found.');
error.statusCode = 404;
throw error;
}
// Rate limiting per user (e.g., 1 request every 2 minutes)
const now = new Date();
const twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000);
if (user.lastOtpSentAt && user.lastOtpSentAt > twoMinutesAgo) {
const error = new Error('Please wait a moment before requesting another OTP.');
error.statusCode = 429; // Too Many Requests
throw error;
}
const verificationMethod = process.env.SINCH_VERIFICATION_METHOD || 'sms';
const transaction = await sequelize.transaction();
try {
// Ensure sinchService and startVerification method exist
if (!sinchService || typeof sinchService.startVerification !== 'function') {
logger.error('Sinch service or startVerification method is not available.');
throw new Error('Verification service is not configured correctly.');
}
const sinchResponse = await sinchService.startVerification(user.phoneNumber, verificationMethod);
if (!sinchResponse || !sinchResponse.id) {
// Log the actual response from Sinch if available
logger.error('Failed to initiate Sinch verification.', { sinchResponse });
throw new Error('Failed to initiate phone verification.');
}
// Store Sinch ID and timestamp
await User.update(
{ sinchVerificationId: sinchResponse.id, lastOtpSentAt: new Date() },
{ where: { id: user.id }, transaction }
);
await transaction.commit();
logger.info(`OTP request initiated for user ${user.id} via ${verificationMethod}. Sinch ID: ${sinchResponse.id}`);
return {
message: `OTP sent successfully via ${verificationMethod} to ${user.phoneNumber}.`,
// verificationId: sinchResponse.id // Avoid sending this to client
};
} catch (error) {
await transaction.rollback();
logger.error(`Error requesting OTP for user ${user?.id}:`, error);
// Propagate Sinch errors or DB errors
if (!error.statusCode) error.statusCode = 500; // Ensure status code
throw error;
}
}
async verifyOtpForUser(identifier, otpCode) {
const findCondition = identifier.email
? { email: identifier.email }
: { phoneNumber: identifier.phoneNumber }; // Assumes E.164 format
const user = await User.findOne({ where: findCondition });
if (!user) {
const error = new Error('User not found.');
error.statusCode = 404;
throw error;
}
const verificationMethod = process.env.SINCH_VERIFICATION_METHOD || 'sms';
let sinchResponse;
try {
// Ensure sinchService and verification methods exist
if (!sinchService || typeof sinchService.reportVerificationById !== 'function' || typeof sinchService.reportVerificationByNumber !== 'function') {
logger.error('Sinch service or verification reporting methods are not available.');
throw new Error('Verification service is not configured correctly.');
}
// Option 1: Verify using the stored ID (preferred if available and recent)
// Check if ID exists and was generated reasonably recently (e.g., within 15 mins) to avoid using stale IDs
const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000);
if (user.sinchVerificationId && user.lastOtpSentAt > fifteenMinutesAgo) {
logger.info(`Verifying OTP for user ${user.id} using Sinch ID: ${user.sinchVerificationId}`);
sinchResponse = await sinchService.reportVerificationById(user.sinchVerificationId, otpCode, verificationMethod);
} else {
// Fallback or primary method: Verify using phone number
logger.info(`Verifying OTP for user ${user.id} using Phone Number: ${user.phoneNumber}`);
sinchResponse = await sinchService.reportVerificationByNumber(user.phoneNumber, otpCode, verificationMethod);
}
// Check Sinch response status
if (sinchResponse && sinchResponse.status === 'SUCCESSFUL') {
const transaction = await sequelize.transaction();
try {
await User.update(
{ isVerified: true, sinchVerificationId: null }, // Mark verified, clear ID
{ where: { id: user.id }, transaction }
);
await transaction.commit();
logger.info(`User ${user.id} successfully verified phone number.`);
const updatedUser = await User.findByPk(user.id, {
attributes: { exclude: ['password', 'sinchVerificationId', 'otpSecret', 'lastOtpSentAt'] }
});
return updatedUser.toJSON();
} catch(dbError) {
await transaction.rollback();
logger.error(`Database error after successful Sinch verification for user ${user.id}:`, dbError);
// Throw a generic server error, as Sinch part succeeded but internal state update failed
const error = new Error('Verification check passed but failed to update account status.');
error.statusCode = 500;
throw error;
}
} else {
// Verification failed
logger.warn(`OTP verification failed for user ${user.id}. Sinch Status: ${sinchResponse?.status}, Reason: ${sinchResponse?.reason}`);
const error = new Error('Invalid or expired OTP code.');
error.statusCode = 401; // Unauthorized or 400 Bad Request
error.details = { sinchStatus: sinchResponse?.status, reason: sinchResponse?.reason };
throw error;
}
} catch (error) {
logger.error(`Error verifying OTP for user ${user?.id}:`, error);
if (!error.statusCode) error.statusCode = 500;
throw error; // Re-throw caught error (could be from Sinch, DB, or logic)
}
}
// --- Helper methods ---
async findUserByPhoneNumber(phoneNumber) {
// Ensure consistent formatting if needed, though input should already be E.164
return User.findOne({ where: { phoneNumber } });
}
async findUserById(userId) {
return User.findByPk(userId);
}
async updateUserVerificationStatus(userId, isVerified) { // Not directly used above, but might be useful elsewhere
await User.update({ isVerified }, { where: { id: userId } });
}
async clearUserSinchVerificationId(userId) { // Not directly used above
await User.update({ sinchVerificationId: null }, { where: { id: userId } });
}
}
module.exports = new AuthService();
Step 6: Implement Auth Controller (src/controllers/auth.controller.js
)
Create the controller using the logger and service.
// src/controllers/auth.controller.js
const authService = require('../services/auth.service');
const logger = require('../utils/logger');
class AuthController {
async register(req, res, next) {
try {
const { email, password, phoneNumber } = req.body;
// Add input validation middleware before this controller runs
const user = await authService.registerUser({ email, password, phoneNumber });
res.status(201).json({ message: 'User registered successfully. Please verify your phone number.', userId: user.id });
} catch (error) {
next(error); // Pass error to the centralized handler
}
}
async login(req, res, next) {
try {
const { email, password } = req.body;
// Add input validation middleware
const user = await authService.loginUser({ email, password });
// If login is successful, but user is not yet verified via OTP
if (!user.isVerified) {
// Initiate OTP process automatically after successful password login
try {
await authService.requestOtpForUser({ email: user.email }); // Request OTP using email
logger.info(`OTP requested automatically for unverified user ${user.email} after login.`);
// Respond indicating OTP is required
return res.status(200).json({
message: 'Login successful (first factor). OTP sent for verification.',
userId: user.id,
requiresOtp: true
});
} catch (otpError) {
logger.error(`Failed to automatically request OTP for ${user.email} after login:`, otpError);
// Proceed with login but indicate OTP step failed to initiate
return res.status(200).json({
message: 'Login successful (first factor). Failed to send OTP automatically. Please request manually.',
userId: user.id,
requiresOtp: true, // Still requires OTP, but needs manual request
otpError: otpError.message // Optionally provide reason
});
}
} else {
// User is already verified, grant access (e.g., issue JWT token - not implemented here)
logger.info(`Verified user ${user.email} logged in successfully.`);
// TODO: Implement session/token generation here
return res.status(200).json({
message: 'Login successful.',
userId: user.id,
requiresOtp: false,
// token: 'your_jwt_token_here' // Example
});
}
} catch (error) {
next(error);
}
}
async requestOtp(req, res, next) {
try {
// Expect identifier (email or phone) in body to find the user
const { email, phoneNumber } = req.body;
if (!email && !phoneNumber) {
return res.status(400).json({ message: 'Email or phone number required to request OTP.' });
}
const identifier = email ? { email } : { phoneNumber }; // Add validation for phone format if used directly
const result = await authService.requestOtpForUser(identifier);
res.status(200).json(result);
} catch (error) {
next(error);
}
}
async verifyOtp(req, res, next) {
try {
// Expect identifier (email or phone) and OTP code
const { email, phoneNumber, otpCode } = req.body;
if ((!email && !phoneNumber) || !otpCode) {
return res.status(400).json({ message: 'Email or phone number, and OTP code are required.' });
}
const identifier = email ? { email } : { phoneNumber }; // Add validation
const verifiedUser = await authService.verifyOtpForUser(identifier, otpCode);
// OTP verification successful, grant access (e.g., issue JWT token)
logger.info(`User ${verifiedUser.email} completed OTP verification.`);
// TODO: Implement session/token generation here
res.status(200).json({
message: 'Phone number verified successfully. Access granted.',
userId: verifiedUser.id,
// token: 'your_jwt_token_here' // Example
});
} catch (error) {
next(error);
}
}
}
module.exports = new AuthController();