Step8.1 Login With Otp
✅ Step 8.1: Login & Login with OTP (Email + Redis + JWT)
🎯 Goal
- Login using username/email/mobile + password
- Generate & email a 6-digit OTP (expires in 5 minutes)
- Verify OTP and issue JWT access + refresh tokens
- Keep existing signup code unchanged — we will only add new code
⚙️ 0) Install & Env
a) Install required packages
npm i @nestjs/jwt jsonwebtoken
npm i -D @types/jsonwebtoken
b) Verify env in .env
# ── Security (JWT) ────────────────────────────────────
JWT_SECRET=AeroStitch@2000
PASSWORD_SALT_ROUNDS=10
JWT_EXPIRES_IN=7d
JWT_REFRESH_EXPIRES_IN=365d
# ─ Brute-force / Lock settings ─
LOGIN_MAX_ATTEMPTS=7
LOGIN_LOCK_MINUTES=60
⚙️ 1) DTOs
File: apps/auth-service/src/dto/login.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';
export class LoginDto {
@IsString() @IsNotEmpty()
usernameOrEmailOrMobile: string;
@IsString() @IsNotEmpty()
password: string;
}
File: apps/auth-service/src/dto/verify-otp.dto.ts
import { IsString, IsNotEmpty, IsUUID, Length } from 'class-validator';
export class VerifyOtpDto {
@IsUUID()
loginId: string; // temporary ID that ties to OTP record in Redis
@IsString() @IsNotEmpty() @Length(6, 6)
otp: string;
@IsString()
deviceName?: string; // optional label e.g. "Chrome on Mac"
}
⚙️ 2) Email Template — placed in email-lib
File: libs/email-lib/src/templates/otp-login.template.ts
/**
* otpLoginTemplate — Professional, branded OTP email
* Usage: sendMail(to, subject, otpLoginTemplate, { name, otp, year }, textFallback)
*/
export const otpLoginTemplate = `
<table width="100%" cellpadding="0" cellspacing="0" style="font-family:Arial,sans-serif;background:#f5f7fb;padding:24px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px
rgba(30,42,62,0.08);">
<tr>
<td style="background:#0d6efd;color:#ffffff;text-align:center;padding:22px;">
<div style="font-size:20px;font-weight:600;letter-spacing:0.3px;">DARMIST Lab</div>
<div style="opacity:0.9;font-size:12px;margin-top:4px;">Secure Login Verification</div>
</td>
</tr>
<tr>
<td style="padding:32px 28px;text-align:center;">
<h2 style="margin:0 0 12px;font-size:22px;color:#1f2937;">Hello {{name}},</h2>
<p style="margin:0 0 18px;font-size:15px;color:#374151;">
Use the One-Time Password (OTP) below to continue your login.
</p>
<div style="display:inline-block;margin:10px 0 18px;padding:12px 22px;border:2px dashed
#0d6efd;border-radius:10px;font-size:30px;font-weight:700;color:#0d6efd;letter-spacing:6px;background:#f0f6ff;">
{{otp}}
</div>
<p style="font-size:13px;color:#6b7280;margin:0 0 4px;">This code will expire in <strong>5 minutes</strong>.</p>
<p style="font-size:12px;color:#9ca3af;margin:0;">If you didn’t request this, you can safely ignore this email.</p>
<p style="font-size:13px;color:#6b7280;margin: 50px 0 0;border-top: 1px solid #6b728084;padding-top: 10px;">Warm regards,<br>DARMIST Lab Team</p>
</td>
</tr>
<tr>
<td style="background:#f9fafb;text-align:center;padding:14px;color:#6b7280;font-size:12px;">
© {{year}} DARMIST Lab — All rights reserved.
</td>
</tr>
</table>
</td>
</tr>
</table>
`;
⚙️ 3) User Schema
import { Schema } from 'mongoose';
export const SessionSchema = new Schema(
{
sessionId: { type: String, required: true },
deviceName: { type: String },
ip: { type: String },
ua: { type: String },
refreshHash: { type: String },
createdAt: { type: Date, default: Date.now },
lastSeen: { type: Date, default: Date.now },
revokedAt: { type: Date },
},
{ _id: false },
);
export const UserSchema = new Schema(
{
name: { type: String, required: true },
username: { type: String, required: true, unique: true },
email: { type: String, required: true, unique: true },
mobile: { type: String, required: true },
password: { type: String, required: true },
role: { type: String, enum: ['user', 'manager', 'admin'], default: 'user' },
status: { type: Number, default: 1 }, // 1 = active
sessions: { type: [SessionSchema], default: [] }, // 👈 for Step 8.3
},
{ timestamps: true },
);
⚙️ 4) Module — Register JwtModule
File: apps/auth-service/src/auth-service.module.ts
import { Module, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { RedisLibModule } from '@app/redis-lib';
import { DatabaseLibService } from '@app/database-lib';
import { TenantMiddleware } from '@app/database-lib/tenant.middleware';
import { AuthServiceService } from './auth-service.service';
import { EmailLibService } from '@app/email-lib';
import { AuthServiceController } from './auth-service.controller';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
RedisLibModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (cfg: ConfigService) => {
const expiresIn = cfg.get<string>('JWT_EXPIRES_IN', '15m');
return {
secret: cfg.get<string>('JWT_SECRET'),
signOptions: { expiresIn: expiresIn as any },
};
},
}),
ClientsModule.registerAsync({
name: 'TENANT_SERVICE',
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
transport: Transport.TCP,
options: {
host: '0.0.0.0',
port: cfg.get<number>('TENANT_SERVICE_TCP_PORT', 4503),
},
}),
}),
],
controllers: [AuthServiceController],
providers: [AuthServiceService, DatabaseLibService, EmailLibService],
})
export class AuthServiceModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes('*');
}
}
⚙️ 5) Service — Add Login & Verify methods (keep signup as-is)
File: apps/auth-service/src/auth-service.service.ts
🔵 Important: Do not delete your existing signup() implementation. Add the following methods in the same class (imports first).
import { Injectable, Logger } from '@nestjs/common';
import { apiResponse } from '@app/common-lib';
import { SignupDto } from './dto/signup.dto';
import { UserSchema } from './schemas/user.schema';
import { EmailLibService } from '@app/email-lib';
import * as bcrypt from 'bcrypt';
import { welcomeTemplate } from '@app/email-lib/templates/welcome.template';
import { LoginDto } from './dto/login.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { RedisLibService } from '@app/redis-lib';
import { otpLoginTemplate } from '@app/email-lib/templates/otp-login.template';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class AuthServiceService {
private readonly logger = new Logger(AuthServiceService.name);
constructor(
private readonly mailer: EmailLibService,
private readonly redis: RedisLibService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
// ───────────────────────────────
// LOGIN → Validate credentials & send OTP
// ───────────────────────────────
async login(req: any, dto: LoginDto) {
try {
const conn = req.tenantConnection;
if (!conn) {
this.logger.warn(
`❌ Tenant connection missing (tenantId=${req.tenantId})`,
);
return apiResponse(
'Login failed: Tenant environment is not initialized. Please retry after selecting the correct workspace.',
null,
{
status: 'error',
code: 'TENANT_CONNECTION_MISSING',
details: { tenantId: req.tenantId },
},
);
}
const User = conn.model('User', UserSchema);
const user = await User.findOne({
$or: [
{ username: dto.usernameOrEmailOrMobile },
{ email: dto.usernameOrEmailOrMobile },
{ mobile: dto.usernameOrEmailOrMobile },
],
});
if (!user) {
this.logger.warn(
`❌ Login failed: user not found for ${dto.usernameOrEmailOrMobile} (tenantId=${req.tenantId})`,
);
return apiResponse(
'Invalid credentials. Please check your username, email, or mobile number and try again.',
null,
{
status: 'error',
code: 'INVALID_CREDENTIALS',
field: 'usernameOrEmailOrMobile',
},
);
}
if (user.status === 0) {
this.logger.warn(`🔒 Locked account tried to login: ${user.email}`);
return apiResponse(
'Your account is currently locked. Please check your email for unlock instructions or contact support.',
null,
{
status: 'error',
code: 'ACCOUNT_LOCKED',
email: user.email,
},
);
}
const valid = await bcrypt.compare(dto.password, user.password);
if (!valid) {
this.logger.warn(`❌ Invalid password for user ${user.email}`);
return apiResponse(
'Incorrect password. Please try again or reset your password if forgotten.',
null,
{
status: 'error',
code: 'INVALID_PASSWORD',
field: 'password',
},
);
}
await this.redis.del(`login:fail:${req.tenantId}:${user._id}`);
const loginId = uuidv4();
const otp = Math.floor(100000 + Math.random() * 900000).toString();
await this.redis.set(
`otp:login:${loginId}`,
JSON.stringify({ userId: user._id, otp }),
300,
);
try {
await this.mailer.sendMail(
user.email,
'DARMIST Lab Login OTP',
otpLoginTemplate,
{ name: user.name, otp, year: new Date().getFullYear() },
`Your DARMIST Lab OTP is ${otp}`,
);
this.logger.log(`📧 OTP sent to ${user.email} (loginId=${loginId})`);
} catch (mailErr) {
this.logger.error(
`📧 Failed to send OTP email to ${user.email}`,
mailErr.stack,
);
return apiResponse(
'We could not send the OTP to your email at this moment. Please try again later.',
null,
{
status: 'error',
code: 'OTP_SEND_FAILED',
error: mailErr.message,
},
);
}
return apiResponse(
'A verification OTP has been sent to your registered email address. Please check your inbox.',
{
loginId,
channel: 'email',
maskedEmail: this.maskEmail(user.email),
},
{
status: 'success',
code: 'OTP_SENT',
},
);
} catch (err: any) {
this.logger.error(`❌ Unexpected login error`, err.stack || err);
return apiResponse(
'Login failed due to a system error. Please try again later.',
null,
{
status: 'error',
code: 'INTERNAL_ERROR',
error: err.message || 'Unknown error',
},
);
}
}
// ───────────────────────────────
// VERIFY OTP → Issue tokens + Save session
// ───────────────────────────────
async verifyOtp(req: any, dto: VerifyOtpDto) {
try {
const conn = req.tenantConnection;
if (!conn) {
this.logger.warn(
`❌ Tenant connection missing (tenantId=${req.tenantId})`,
);
return apiResponse(
'OTP verification failed: Tenant environment not initialized. Please retry.',
null,
{
status: 'error',
code: 'TENANT_CONNECTION_MISSING',
details: { tenantId: req.tenantId },
},
);
}
const data = await this.redis.get(`otp:login:${dto.loginId}`);
if (!data) {
this.logger.warn(
`❌ OTP expired or invalid (loginId=${dto.loginId}, tenantId=${req.tenantId})`,
);
return apiResponse(
'Your OTP has expired or is invalid. Please request a new OTP to continue.',
null,
{
status: 'error',
code: 'OTP_EXPIRED_OR_INVALID',
loginId: dto.loginId,
},
);
}
let parsed: { userId: string; otp: string };
try {
parsed = JSON.parse(
typeof data === 'string' ? data : JSON.stringify(data),
);
} catch (err) {
this.logger.error(
`❌ Failed to parse OTP data (loginId=${dto.loginId})`,
err?.stack || '',
);
return apiResponse(
'Verification failed due to corrupted OTP data. Please try again.',
null,
{
status: 'error',
code: 'OTP_DATA_CORRUPTED',
loginId: dto.loginId,
},
);
}
const { userId, otp } = parsed;
const User = conn.model('User', UserSchema);
const user = await User.findById(userId);
if (!user) {
this.logger.warn(
`❌ OTP verification failed: user not found (userId=${userId}, tenantId=${req.tenantId})`,
);
return apiResponse(
'User not found. Please reinitiate the login process.',
null,
{
status: 'error',
code: 'USER_NOT_FOUND',
userId,
},
);
}
if (dto.otp !== otp) {
this.logger.warn(
`❌ OTP mismatch for user ${user.email} (tenantId=${req.tenantId}, provided=${dto.otp}, expected=${otp})`,
);
return apiResponse(
'Incorrect OTP entered. Please check and try again.',
null,
{
status: 'error',
code: 'INVALID_OTP',
field: 'otp',
},
);
}
await this.redis.del(`otp:login:${dto.loginId}`);
const sessionId = uuidv4();
const payload = {
sub: String(user._id),
tenantId: req.tenantId,
username: user.username,
role: user.role,
sid: sessionId,
};
const accessToken = this.jwt.sign(payload, {
expiresIn: this.config.get('JWT_EXPIRES_IN', '15m'),
});
const refreshToken = this.jwt.sign(payload, {
expiresIn: this.config.get('JWT_REFRESH_EXPIRES_IN', '7d'),
});
const crypto = await import('crypto');
const refreshHash = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');
const session = {
sessionId,
deviceName: req.headers?.['user-agent'] || 'Unknown',
ip: req.ip || req.connection?.remoteAddress || 'N/A',
ua: req.headers?.['user-agent'] || 'Unknown',
refreshHash,
createdAt: new Date(),
lastSeen: new Date(),
};
await User.updateOne({ _id: user._id }, { $push: { sessions: session } });
this.logger.log(
`✅ User ${user.email} logged in successfully (tenantId=${req.tenantId}, sessionId=${sessionId})`,
);
return apiResponse(
'You have successfully logged in to DARMIST Lab.',
{
accessToken,
refreshToken,
sessionId,
user: { id: user._id, username: user.username, role: user.role },
},
{
status: 'success',
code: 'LOGIN_SUCCESS',
},
);
} catch (err: any) {
this.logger.error(
`❌ Unexpected error in verifyOtp (tenantId=${req.tenantId}, loginId=${dto.loginId})`,
err?.stack || err,
);
return apiResponse(
'Login verification failed due to an unexpected system error. Please try again later.',
null,
{
status: 'error',
code: 'INTERNAL_ERROR',
error: err.message || 'Unknown error',
},
);
}
}
// ───────────────────────────────
// HELPERS
// ───────────────────────────────
private maskEmail(email: string) {
const [name, domain] = email.split('@');
return name[0] + '***@' + domain;
}
}
⚙️ 6) Controller — Add Endpoints
File: apps/auth-service/src/auth-service.controller.ts
(Append below your existing signup handlers.)
import { Body, Controller, Post, Req } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { apiResponse } from '@app/common-lib';
import { AuthServiceService } from './auth-service.service';
import { LoginDto } from './dto/login.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
@Controller('auth')
export class AuthServiceController {
constructor(private readonly service: AuthServiceService) {}
// ───────────────────────────────
// HTTP Endpoint: Login (send OTP)
// ───────────────────────────────
@Post('login')
async loginHttp(@Req() req: any, @Body() dto: LoginDto) {
const result = await this.service.login(req, dto);
return result;
}
// ───────────────────────────────
// HTTP Endpoint: Login → Verify OTP
// ───────────────────────────────
@Post('login/verify')
async verifyOtpHttp(@Req() req: any, @Body() dto: VerifyOtpDto) {
const result = await this.service.verifyOtp(req, dto);
return result;
}
// ───────────────────────────────
// TCP Endpoint: Login (send OTP)
// ───────────────────────────────
@MessagePattern({ cmd: 'auth.login' })
async loginTcp(@Payload() payload: LoginDto & { tenantConnection?: any }) {
const result = await this.service.login(
{ tenantConnection: payload.tenantConnection },
payload,
);
return result;
}
// ───────────────────────────────
// TCP Endpoint: Login → Verify OTP
// ───────────────────────────────
@MessagePattern({ cmd: 'auth.verifyOtp' })
async verifyOtpTcp(
@Payload() payload: VerifyOtpDto & { tenantConnection?: any },
) {
const result = await this.service.verifyOtp(
{ tenantConnection: payload.tenantConnection },
payload,
);
return result;
}
}
⚙️ 7) cURL Tests
a) Signup
curl -X POST http://localhost:3502/auth/signup \
-H "Content-Type: application/json" \
-H "x-tenant-id: darmist1" \
-d '{
"name": "Test User",
"username": "testuser",
"email": "testuser@darmist.com",
"mobile": "01710000000",
"password": "secret123"
}' | jq
b) Request OTP
curl -X POST http://localhost:3502/auth/login \
-H "Content-Type: application/json" \
-H "x-tenant-id: darmist1" \
-d '{"usernameOrEmailOrMobile":"testuser","password":"secret123"}' | jq
Expected (200):
{
"message": "A verification OTP has been sent to your registered email address. Please check your inbox.",
"data": {
"loginId": "c1e0d2ba-237e-47e1-b18b-d86a092c4058",
"channel": "email",
"maskedEmail": "t***@darmist.com"
},
"meta": {
"status": "success",
"code": "OTP_SENT"
},
"ts": "2025-11-06T05:24:58.249Z"
}
c) Verify OTP
curl -X POST http://localhost:3502/auth/login/verify \
-H "Content-Type: application/json" \
-H "x-tenant-id: darmist1" \
-d '{"loginId":"c1e0d2ba-237e-47e1-b18b-d86a092c4058","otp":"903335"}' | jq
Expected (200):
{
"message": "You have successfully logged in to DARMIST Lab.",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2OTBjMzBmNGIxNmU5NTkxNTgwMWU0ZTUiLCJ1c2VybmFtZSI6InRlc3R1c2VyIiwicm9sZSI6InVzZXIiLCJzaWQiOiJlOGM3ZmE1Zi0xNjZiLTRkZTItYjJlNC0yZDI5MGM1N2NmNTgiLCJpYXQiOjE3NjI0MDY3ODUsImV4cCI6MTc2MzAxMTU4NX0.PdZzsgGRAJkfJgodjwnkW8vY-IZqI5uevkU_BGk3W0w",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2OTBjMzBmNGIxNmU5NTkxNTgwMWU0ZTUiLCJ1c2VybmFtZSI6InRlc3R1c2VyIiwicm9sZSI6InVzZXIiLCJzaWQiOiJlOGM3ZmE1Zi0xNjZiLTRkZTItYjJlNC0yZDI5MGM1N2NmNTgiLCJpYXQiOjE3NjI0MDY3ODUsImV4cCI6MTc5Mzk0Mjc4NX0.gCdRm6N-UFKYvGxe09pzo_MkL6Hkg7wrW-Ff90KNWeY",
"sessionId": "e8c7fa5f-166b-4de2-b2e4-2d290c57cf58",
"user": {
"id": "690c30f4b16e95915801e4e5",
"username": "testuser",
"role": "user"
}
},
"meta": {
"status": "success",
"code": "LOGIN_SUCCESS"
},
"ts": "2025-11-06T05:26:25.829Z"
}
d) Wrong password (should not reveal which field)
curl -X POST http://localhost:3502/auth/login \
-H "Content-Type: application/json" \
-H "x-tenant-id: darmist1" \
-d '{"usernameOrEmailOrMobile":"testuser","password":"wrong"}' | jq
Expected (401):
{
"message": "Incorrect password. Please try again or reset your password if forgotten.",
"data": null,
"meta": {
"status": "error",
"code": "INVALID_PASSWORD",
"field": "password"
},
"ts": "2025-11-06T05:31:44.081Z"
}
e) Wrong OTP
curl -X POST http://localhost:3502/auth/login/verify \
-H "Content-Type: application/json" \
-H "x-tenant-id: darmist1" \
-d '{"loginId":"e2b8f7a2-....-4a9c","otp":"000000"}'
Expected (401):
"Invalid OTP"
🎉 End of Step 8.1
You now have:
- Login → OTP email (5 min TTL) → Verify → JWT tokens
- A polished email template and complete cURL test set