forgot password in login page and account info implemented, sms.ir service works fine with sandbox key.

This commit is contained in:
2026-07-04 00:01:58 +03:30
parent 2f95559087
commit 3f7364dbf0
26 changed files with 1000 additions and 38 deletions

View File

@@ -0,0 +1,20 @@
const IRAN_MOBILE_PATTERN = /^9\d{9}$/;
/** Normalize Iranian mobile to sms.ir format (e.g. 9123456789). */
export function normalizeIranMobile(input: string): string {
let digits = input.replace(/\D/g, '');
if (digits.startsWith('98') && digits.length === 12) {
digits = digits.slice(2);
}
if (digits.startsWith('0') && digits.length === 11) {
digits = digits.slice(1);
}
return digits;
}
export function isValidIranMobile(input: string): boolean {
return IRAN_MOBILE_PATTERN.test(normalizeIranMobile(input));
}

View File

@@ -46,6 +46,10 @@ export interface Config {
ttl: number;
limit: number;
};
sms: {
apiKey: string | null;
templateId: number;
};
}
export default (): Config => {
@@ -100,5 +104,9 @@ export default (): Config => {
ttl: getEnvVarAsNumber('THROTTLE_TTL', 60),
limit: getEnvVarAsNumber('THROTTLE_LIMIT', 100),
},
sms: {
apiKey: process.env.SMS_IR_API_KEY?.trim() || null,
templateId: getEnvVarAsNumber('SMS_IR_TEMPLATE_ID', 123456),
},
};
};

View File

@@ -31,11 +31,17 @@ import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { UpdateLanguageDto } from './dto/update-language.dto';
import {
ForgotPasswordSendCodeDto,
ForgotPasswordVerifyDto,
} from './dto/forgot-password.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
private static readonly REMEMBER_ME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
private static readonly PASSWORD_RESET_MAX_AGE_MS = 15 * 60 * 1000;
constructor(private readonly authService: AuthService) {}
@@ -170,6 +176,67 @@ export class AuthController {
return this.authService.updateLanguage(req.user.id, dto);
}
@Patch('profile/password')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Change account password' })
async changePassword(
@Req() req,
@Body() dto: ChangePasswordDto,
@Res({ passthrough: true }) res: Response,
) {
const skipCurrentPassword = req?.cookies?.passwordResetVerified === '1';
const result = await this.authService.changePassword(
req.user.id,
dto.currentPassword,
dto.newPassword,
skipCurrentPassword,
);
this.clearAuthCookies(res);
res.clearCookie('passwordResetVerified', this.baseCookieOptions());
return result;
}
@Post('forgot-password/send-code')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send forgot-password SMS verification code' })
async sendForgotPasswordCode(@Body() dto: ForgotPasswordSendCodeDto) {
return this.authService.sendForgotPasswordCode(dto);
}
@Post('forgot-password/verify')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify SMS code and sign in for password reset' })
async verifyForgotPasswordCode(
@Body() dto: ForgotPasswordVerifyDto,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.authService.verifyForgotPasswordCode(dto);
this.setAuthCookies(
res,
result.data.accessToken,
result.data.refreshToken,
);
res.cookie('passwordResetVerified', '1', {
...this.baseCookieOptions(),
maxAge: AuthController.PASSWORD_RESET_MAX_AGE_MS,
});
return {
success: true,
data: {
user: result.data.user,
organizations: result.data.organizations,
redirectTo: '/settings/account',
},
};
}
@Get('subscription-alert')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@@ -308,5 +375,6 @@ export class AuthController {
res.clearCookie('accessToken', options);
res.clearCookie('refreshToken', options);
res.clearCookie('authRemember', options);
res.clearCookie('passwordResetVerified', options);
}
}

View File

@@ -8,10 +8,12 @@ import { AuthController } from './auth.controller';
import { PrismaService } from '../../../prisma/prisma.service';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { SmsModule } from '../sms/sms.module';
@Module({
imports: [
PassportModule,
SmsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({

View File

@@ -20,6 +20,18 @@ import {
UpdateLanguageDto,
} from './dto/update-language.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface';
import { SmsService } from '../sms/sms.service';
import {
ForgotPasswordSendCodeDto,
ForgotPasswordVerifyDto,
} from './dto/forgot-password.dto';
import { normalizeIranMobile } from '../../common/utils/mobile.util';
import * as crypto from 'crypto';
const FORGOT_PASSWORD_PURPOSE = 'forgot_password';
const VERIFICATION_CODE_TTL_MS = 10 * 60 * 1000;
const SEND_CODE_COOLDOWN_MS = 60 * 1000;
const PASSWORD_RESET_WINDOW_MS = 15 * 60 * 1000;
const ALL_PERMISSIONS = [
'TAB_TODAY_READ',
@@ -57,6 +69,7 @@ export class AuthService {
private prisma: PrismaService,
private jwtService: JwtService,
private configService: ConfigService,
private smsService: SmsService,
) { }
private accessJwtSignOptions(): JwtSignOptions {
@@ -203,13 +216,24 @@ export class AuthService {
const { password, name, organizationName, organizationEmail, organizationType } = registerDto;
const email = registerDto.email.trim().toLowerCase();
if (!RegisterDto.isValidMobile(registerDto.mobile)) {
throw new BadRequestException('Please enter a valid mobile number');
}
const mobile = normalizeIranMobile(registerDto.mobile);
// 1. Check existing user
const existingUser = await this.prisma.user.findUnique({
where: { email },
const existingUser = await this.prisma.user.findFirst({
where: {
OR: [{ email }, { mobile }],
},
});
if (existingUser) {
throw new ConflictException('User already exists. Please login and create a new organization from your account.');
if (existingUser.email === email) {
throw new ConflictException('User already exists. Please login and create a new organization from your account.');
}
throw new ConflictException('This mobile number is already registered.');
}
// 2. Hash password
@@ -221,6 +245,7 @@ export class AuthService {
const user = await tx.user.create({
data: {
email,
mobile,
passwordHash: hashedPassword,
name,
trialUsedAt: new Date(),
@@ -526,11 +551,17 @@ export class AuthService {
/**
* Change user password
* @param userId - User ID
* @param oldPassword - Current password
* @param oldPassword - Current password (optional when reset verified via SMS)
* @param newPassword - New password
* @param skipCurrentPassword - True when user verified mobile via forgot-password flow
* @returns Success message
*/
async changePassword(userId: string, oldPassword: string, newPassword: string) {
async changePassword(
userId: string,
oldPassword: string | undefined,
newPassword: string,
skipCurrentPassword = false,
) {
try {
const user = await this.prisma.user.findUnique({
where: { id: userId },
@@ -540,22 +571,29 @@ export class AuthService {
throw new BadRequestException('User not found or invalid password method');
}
// Verify old password
const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException('Current password is incorrect');
if (skipCurrentPassword) {
const hasRecentReset = await this.hasRecentPasswordResetVerification(userId);
if (!hasRecentReset) {
throw new UnauthorizedException('Password reset verification expired. Please verify your mobile again.');
}
} else {
if (!oldPassword) {
throw new BadRequestException('Current password is required');
}
const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException('Current password is incorrect');
}
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash: hashedPassword },
});
// Invalidate all sessions for this user (force re-login)
await this.prisma.session.deleteMany({
where: { userId },
});
@@ -572,6 +610,156 @@ export class AuthService {
}
}
async sendForgotPasswordCode(dto: ForgotPasswordSendCodeDto) {
if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) {
throw new BadRequestException('Please enter a valid mobile number');
}
const mobile = normalizeIranMobile(dto.mobile);
const user = await this.prisma.user.findUnique({
where: { mobile },
select: { id: true },
});
if (!user) {
return {
success: true,
message: 'If this mobile number is registered, a verification code has been sent.',
};
}
const recentCode = await this.prisma.phoneVerificationCode.findFirst({
where: {
mobile,
purpose: FORGOT_PASSWORD_PURPOSE,
createdAt: { gt: new Date(Date.now() - SEND_CODE_COOLDOWN_MS) },
},
orderBy: { createdAt: 'desc' },
});
if (recentCode) {
throw new BadRequestException('Please wait before requesting another code');
}
const code = this.generateVerificationCode();
const codeHash = this.hashVerificationCode(code);
await this.prisma.phoneVerificationCode.create({
data: {
userId: user.id,
mobile,
codeHash,
purpose: FORGOT_PASSWORD_PURPOSE,
expiresAt: new Date(Date.now() + VERIFICATION_CODE_TTL_MS),
},
});
await this.smsService.sendVerificationCode(mobile, code);
if (this.configService.get<string>('NODE_ENV') === 'development') {
console.log(`[dev] forgot-password code for ${mobile}: ${code}`);
}
return {
success: true,
message: 'If this mobile number is registered, a verification code has been sent.',
};
}
async verifyForgotPasswordCode(dto: ForgotPasswordVerifyDto) {
if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) {
throw new BadRequestException('Please enter a valid mobile number');
}
const mobile = normalizeIranMobile(dto.mobile);
const code = dto.code.trim();
const verification = await this.prisma.phoneVerificationCode.findFirst({
where: {
mobile,
purpose: FORGOT_PASSWORD_PURPOSE,
verifiedAt: null,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
});
if (!verification || !this.isVerificationCodeValid(code, verification.codeHash)) {
throw new UnauthorizedException('Invalid or expired verification code');
}
await this.prisma.phoneVerificationCode.update({
where: { id: verification.id },
data: { verifiedAt: new Date() },
});
const user = await this.prisma.user.findUnique({
where: { mobile },
include: {
memberships: {
include: {
organization: {
include: {
type: true,
plan: true,
},
},
permissions: {
include: {
permission: true,
},
},
},
},
},
});
if (!user) {
throw new UnauthorizedException('Invalid or expired verification code');
}
const loginResult = await this.login(
{ email: user.email, password: '' } as LoginDto,
user,
);
return {
success: true,
data: {
accessToken: loginResult.data.accessToken,
refreshToken: loginResult.data.refreshToken,
user: loginResult.data.user,
organizations: loginResult.data.organizations,
passwordResetVerified: true,
},
};
}
async hasRecentPasswordResetVerification(userId: string): Promise<boolean> {
const recent = await this.prisma.phoneVerificationCode.findFirst({
where: {
userId,
purpose: FORGOT_PASSWORD_PURPOSE,
verifiedAt: { gt: new Date(Date.now() - PASSWORD_RESET_WINDOW_MS) },
},
orderBy: { verifiedAt: 'desc' },
});
return Boolean(recent);
}
private generateVerificationCode(): string {
return String(Math.floor(10000 + Math.random() * 90000));
}
private hashVerificationCode(code: string): string {
return crypto.createHash('sha256').update(code).digest('hex');
}
private isVerificationCodeValid(code: string, codeHash: string): boolean {
return this.hashVerificationCode(code.trim()) === codeHash;
}
/**
* Get all active sessions for a user
* @param userId - User ID
@@ -954,12 +1142,14 @@ export class AuthService {
email: string;
name: string;
language?: string | null;
mobile?: string | null;
}) {
return {
id: user.id,
email: user.email,
name: user.name,
language: user.language ?? 'en',
mobile: user.mobile ?? null,
};
}
}

View File

@@ -0,0 +1,11 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsOptional()
@IsString()
currentPassword?: string;
@IsString()
@MinLength(8)
newPassword: string;
}

View File

@@ -0,0 +1,22 @@
import { IsString, Matches, Length } from 'class-validator';
import { isValidIranMobile } from '../../../common/utils/mobile.util';
export class ForgotPasswordSendCodeDto {
@IsString()
@Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' })
mobile: string;
static validateMobile(mobile: string): boolean {
return isValidIranMobile(mobile);
}
}
export class ForgotPasswordVerifyDto {
@IsString()
@Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' })
mobile: string;
@IsString()
@Length(5, 6)
code: string;
}

View File

@@ -1,9 +1,14 @@
import { IsEmail, IsString, MinLength, IsEnum } from 'class-validator';
import { IsEmail, IsString, MinLength, IsEnum, Matches } from 'class-validator';
import { isValidIranMobile } from '../../../common/utils/mobile.util';
export class RegisterDto {
@IsEmail()
email: string;
@IsString()
@Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' })
mobile: string;
@IsString()
@MinLength(8)
password: string;
@@ -19,4 +24,8 @@ export class RegisterDto {
@IsEnum(['CLINIC', 'LAB'])
organizationType: 'CLINIC' | 'LAB';
static isValidMobile(mobile: string): boolean {
return isValidIranMobile(mobile);
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}

View File

@@ -0,0 +1,63 @@
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
interface SmsIrVerifyResponse {
status: number;
message: string;
data?: {
messageId: number;
cost: number;
};
}
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly configService: ConfigService) {}
async sendVerificationCode(mobile: string, code: string): Promise<void> {
const apiKey = this.configService.get<string>('sms.apiKey');
const templateId = this.configService.get<number>('sms.templateId');
if (!apiKey) {
this.logger.warn(`SMS_IR_API_KEY not set — verification code for ${mobile}: ${code}`);
return;
}
let response: Response;
try {
response = await fetch('https://api.sms.ir/v1/send/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
'x-api-key': apiKey,
},
body: JSON.stringify({
mobile,
templateId,
parameters: [{ name: 'Code', value: code }],
}),
});
console.log('request', templateId , apiKey,mobile, code);
console.log('response', response);
} catch (error) {
this.logger.error('sms.ir request failed', error);
throw new InternalServerErrorException('Failed to send verification code');
}
let payload: SmsIrVerifyResponse;
try {
payload = (await response.json()) as SmsIrVerifyResponse;
} catch {
throw new InternalServerErrorException('Invalid response from SMS provider');
}
if (!response.ok || payload.status !== 1) {
this.logger.error(`sms.ir error: ${payload.message}`);
throw new InternalServerErrorException('Failed to send verification code');
}
}
}