Step8.2 Account Lock Unlock
✅ Step 8.2: Account Lockout & Unlock
🎯 Goal
- Enhance login security by:
- Tracking failed login attempts (wrong password or wrong OTP).
- Locking the account after too many failures.
- Sending an unlock email with a secure token.
- Allowing account unlock via
/auth/unlock?token=....
⚙️ 1) Environment Configuration
File: .env
# Lockout configuration
LOGIN_MAX_ATTEMPTS=7
LOGIN_LOCK_MINUTES=60
LOGIN_MAX_ATTEMPTS: number of failed login attempts before lock.
LOGIN_LOCK_MINUTES: lock duration (in minutes).
⚙️ 2) Redis Keys
We’ll use two types of Redis keys:
login:fail:<tenantId>:<userId>→ counter of failed logins (TTL =LOGIN_LOCK_MINUTES).unlock:<token>→ JSON payload{ tenantId, userId }(TTL = 24h).
⚙️ 3) Email Template
File: libs/email-lib/src/templates/account-locked.template.ts
export const accountLockedTemplate = `
<table width="100%" cellpadding="0" cellspacing="0" style="font-family:Arial,sans-serif;background:#f5f7fb;padding:24px;">
<tr>
<td align="center">
<table width="600" style="background:#fff;border-radius:12px;box-shadow:0 8px 24px rgba(30,42,62,0.08);overflow:hidden;">
<tr>
<td style="background:#ef4444;color:#fff;text-align:center;padding:20px;">
<h1 style="margin:0;font-size:20px;">Account Locked</h1>
</td>
</tr>
<tr>
<td style="padding:28px;text-align:center;">
<h2 style="margin:0 0 12px;font-size:22px;color:#1f2937;">Hello {{name}},</h2>
<p style="font-size:15px;color:#374151;">
Too many failed login attempts have locked your DARMIST Lab account.
</p>
<p style="margin:20px 0;font-size:14px;">
Click the button below to unlock your account:
</p>
<a href="{{unlockUrl}}" style="display:inline-block;padding:12px
24px;background:#0d6efd;color:#fff;border-radius:6px;text-decoration:none;font-weight:600;">
Unlock My Account
</a>
<p style="margin-top:24px;font-size:13px;color:#6b7280;">
If you did not try to log in, please change your password once you regain access.
</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;font-size:12px;color:#6b7280;">
© {{year}} DARMIST Lab — All rights reserved.
</td>
</tr>
</table>
</td>
</tr>
</table>
`;
⚙️ 4) Auth Service — Lockout & Unlock Logic
File: apps/auth-service/src/auth-service.service.ts
(add inside the class, alongside existing signup method)
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';
import { accountLockedTemplate } from '@app/email-lib/templates/account-locked.template';
@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) {
await this.recordFailedAttempt(
req.tenantId,
conn,
user._id,
user.email,
user.name,
);
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) {
await this.recordFailedAttempt(
req.tenantId,
conn,
user._id,
user.email,
user.name,
);
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',
},
);
}
}
// ───────────────────────────────
// RECORD FAILED ATTEMPT & LOCK IF LIMIT REACHED
// ───────────────────────────────
private async recordFailedAttempt(
tenantId: string,
conn: any,
userId: string,
email: string,
name: string,
) {
const maxAttempts = this.config.get<number>('LOGIN_MAX_ATTEMPTS', 7);
const lockMinutes = this.config.get<number>('LOGIN_LOCK_MINUTES', 60);
const key = `login:fail:${tenantId}:${userId}`;
const attempts = (parseInt((await this.redis.get(key)) || '0') || 0) + 1;
await this.redis.set(key, attempts.toString(), lockMinutes * 60);
if (attempts >= maxAttempts) {
// Lock user account
const User = conn.model('User', UserSchema);
await User.findByIdAndUpdate(userId, { status: 0 });
// Generate unlock token
const unlockToken = uuidv4();
await this.redis.set(
`unlock:${unlockToken}`,
JSON.stringify({ tenantId, userId }),
86400, // 24h
);
const appBaseUrl = this.config.get<string>('APP_BASE_URL', 'http://localhost:3000');
const unlockUrl = `${appBaseUrl}/auth/unlock?token=${unlockToken}`;
// Send unlock email
await this.mailer.sendMail(
email,
'Your DARMIST Lab Account is Locked',
accountLockedTemplate,
{ name, unlockUrl, year: new Date().getFullYear() },
`Your account is locked. Unlock here: ${unlockUrl}`,
);
this.logger.warn(`🔒 Account locked: ${email}`);
}
}
// ───────────────────────────────
// UNLOCK ACCOUNT
// ───────────────────────────────
async unlock(token: string, conn: any) {
try {
const data = await this.redis.get(`unlock:${token}`);
if (!data) {
this.logger.warn(`❌ Invalid or expired unlock token used`);
return apiResponse(
'The unlock link is invalid or has expired. Please request a new unlock email.',
null,
{ status: 'error', code: 'INVALID_OR_EXPIRED_TOKEN' },
);
}
let parsed: { tenantId: string; userId: string };
try {
parsed = typeof data === 'string' ? JSON.parse(data) : data;
} catch (err) {
this.logger.error(
`❌ Failed to parse unlock token data`,
err?.stack || '',
);
return apiResponse(
'Unlock request failed due to corrupted token data. Please generate a new unlock link.',
null,
{
status: 'error',
code: 'TOKEN_DATA_CORRUPTED',
},
);
}
const { tenantId, userId } = parsed;
if (!tenantId || !userId) {
this.logger.error(
`⚠️ Unlock token data incomplete: ${JSON.stringify(parsed)}`,
);
return apiResponse(
'Unlock request failed due to incomplete token data. Please generate a new unlock link.',
null,
{ status: 'error', code: 'TOKEN_DATA_INCOMPLETE' },
);
}
const User = conn.model('User', UserSchema);
const user = await User.findById(userId);
if (!user) {
this.logger.warn(
`⚠️ User not found for unlock token (userId=${userId}, tenant=${tenantId})`,
);
return apiResponse(
'No user account found for the provided unlock link.',
null,
{ status: 'error', code: 'USER_NOT_FOUND' },
);
}
if (user.status === 1) {
this.logger.log(
`ℹ️ User already unlocked (userId=${userId}, tenant=${tenantId})`,
);
return apiResponse(
'Your account is already unlocked. You can log in using your credentials.',
{ userId, tenantId },
{ status: 'success', code: 'ACCOUNT_ALREADY_UNLOCKED' },
);
}
await User.findByIdAndUpdate(userId, { status: 1 });
await this.redis.del(`unlock:${token}`);
await this.redis.del(`login:fail:${tenantId}:${userId}`); // Clear failed attempts after unlock
this.logger.log(
`✅ Account unlocked successfully: user=${userId}, tenant=${tenantId}`,
);
return apiResponse(
'Your account has been unlocked successfully. You may now log in again.',
{ userId, tenantId },
{
status: 'success',
code: 'ACCOUNT_UNLOCKED',
},
);
} catch (err: any) {
this.logger.error(
`❌ Unexpected error during account unlock`,
err.stack || err,
);
return apiResponse(
'Account unlock failed due to a 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;
}
}
⚙️ 5) Controller — Unlock Endpoint
File: apps/auth-service/src/auth-service.controller.ts
(add inside the controller)
import { Get, Query, 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';
import { BadRequestException } from '@nestjs/common';
@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) {
req.tenantId = req.headers['x-tenant-id'];
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) {
req.tenantId = req.headers['x-tenant-id'];
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(
{ tenantId: payload.tenantConnection },
payload,
);
return result;
}
// ───────────────────────────────
// HTTP Endpoint: Unlock account
// ───────────────────────────────
@Get('unlock')
async unlockHttp(@Req() req: any, @Query('token') token: string) {
const conn = req.tenantConnection;
if (!conn) throw new BadRequestException('Tenant connection not available');
req.tenantId = req.headers['x-tenant-id'];
const result = await this.service.unlock(token, conn);
return result;
}
// ───────────────────────────────
// TCP Endpoint: Unlock account
// ───────────────────────────────
@MessagePattern({ cmd: 'auth.unlock' })
async unlockTcp(
@Payload() payload: { token: string; tenantConnection: any },
) {
const result = await this.service.unlock(
payload.token,
payload.tenantConnection,
);
return result;
}
}
⚙️ 6) cURL Tests
a) Exceed wrong password attempts
for i in {1..8}; do
curl -s -X POST http://localhost:3502/auth/login \
-H "Content-Type: application/json" \
-H "x-tenant-id: darmist1" \
-d '{"usernameOrEmailOrMobile":"testuser","password":"wrong"}' | jq
done
After 7 failures, account is locked. Unlock email is sent.
b) Unlock account
curl -H "Content-Type: application/json" -H "x-tenant-id: darmist1" "http://localhost:3502/auth/unlock?token=71da8c27-a42b-4497-85f3-8f9f55dd230e" | jq
Expected (200):
{
"message": "Your account has been unlocked successfully. You may now log in again.",
"data": {
"userId": "690c30f4b16e95915801e4e5",
"tenantId": "darmist1"
},
"meta": {
"status": "success",
"code": "ACCOUNT_UNLOCKED"
},
"ts": "2025-11-06T06:48:11.258Z"
}
🎉 End of Step 8.2
You now have:
- Lockout after too many failures
- Unlock via email link