forgot password in login page and account info implemented, sms.ir service works fine with sandbox key.
This commit is contained in:
20
backend/src/common/utils/mobile.util.ts
Normal file
20
backend/src/common/utils/mobile.util.ts
Normal 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));
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
11
backend/src/modules/auth/dto/change-password.dto.ts
Normal file
11
backend/src/modules/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currentPassword?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
22
backend/src/modules/auth/dto/forgot-password.dto.ts
Normal file
22
backend/src/modules/auth/dto/forgot-password.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
8
backend/src/modules/sms/sms.module.ts
Normal file
8
backend/src/modules/sms/sms.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
63
backend/src/modules/sms/sms.service.ts
Normal file
63
backend/src/modules/sms/sms.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user