feat(auth): mobile-only register/login with OTP verification

- Register and login by mobile number; email is now an optional
  contact field only (never used to authenticate)
- After registration, the phone is verified via a 6-digit SMS code
- Login supports both password and one-time-code (OTP) methods
- Phone OTP delivered via Kavenegar (verify/lookup); API key in env
- Account page: edit name/optional email, change password, and
  change mobile number with OTP re-verification
- Codes are hashed, expire in 5m, capped at 5 attempts, rate-limited
- Seed gives the admin a verified phone so mobile login still works

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-16 16:40:08 +03:30
parent ce6813db99
commit 37c103fa20
31 changed files with 1756 additions and 143 deletions
+10
View File
@@ -20,6 +20,16 @@ JWT_REFRESH_EXPIRES_IN=7d
REDIS_HOST=localhost
REDIS_PORT=6379
# OTP SMS — Kavenegar (verify/lookup template API)
# Create an approved OTP template in the Kavenegar panel and put its name here.
# The template must contain a single %token placeholder for the code.
KAVENEGAR_API_KEY=
KAVENEGAR_OTP_TEMPLATE=
# Without these, OTP codes are logged to the API console in development only.
# Seed admin mobile (login is mobile-only) — used by `npm run seed`.
ADMIN_PHONE=09120000000
# In-cluster Docker Registry (Kaniko push + app image pull — same URL)
REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000
# REGISTRY_PULL_URL=registry.cloudhost-builds.svc.cluster.local:5000
@@ -0,0 +1,33 @@
-- Mobile-first auth: phone is the login identifier, email becomes an optional
-- contact field, plus a table of short-lived one-time SMS codes for verifying
-- a phone (registration/login completion and number changes).
-- Email becomes optional (login no longer uses it). Postgres treats NULLs as
-- distinct, so the existing UNIQUE constraint keeps working for users without one.
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR;
ALTER TABLE users ADD COLUMN IF NOT EXISTS "phoneVerified" BOOLEAN NOT NULL DEFAULT FALSE;
-- Unique per non-null phone (NULLs allowed for legacy email-only staff accounts).
CREATE UNIQUE INDEX IF NOT EXISTS users_phone_unique ON users (phone) WHERE phone IS NOT NULL;
-- One-time SMS verification codes (hashed).
DO $$ BEGIN
CREATE TYPE verification_codes_purpose_enum AS ENUM ('login', 'change_phone');
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TABLE IF NOT EXISTS verification_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purpose verification_codes_purpose_enum NOT NULL,
destination VARCHAR NOT NULL,
"codeHash" VARCHAR NOT NULL,
"expiresAt" TIMESTAMPTZ NOT NULL,
attempts INT NOT NULL DEFAULT 0,
"consumedAt" TIMESTAMPTZ,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS verification_codes_user_purpose_idx
ON verification_codes ("userId", purpose);
+21 -5
View File
@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { OtpRequestDto, OtpVerifyDto } from './dto/otp.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@ApiTags('Authentication')
@@ -11,22 +12,37 @@ export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiResponse({ status: 201, description: 'User registered successfully' })
@ApiResponse({ status: 409, description: 'Email already registered' })
@ApiOperation({ summary: 'Register with a mobile number (sends a verification code)' })
@ApiResponse({ status: 201, description: 'Account created; phone verification required' })
@ApiResponse({ status: 409, description: 'Mobile number already registered' })
async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with email and password' })
@ApiResponse({ status: 200, description: 'Login successful' })
@ApiOperation({ summary: 'Login with mobile and password' })
@ApiResponse({ status: 200, description: 'Login successful (or verification required)' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@Post('otp/request')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send a one-time login code to a mobile number' })
async requestOtp(@Body() dto: OtpRequestDto) {
return this.authService.requestOtp(dto.phone);
}
@Post('otp/verify')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify a one-time code (registration completion or OTP login)' })
@ApiResponse({ status: 200, description: 'Verified; login successful' })
async verifyOtp(@Body() dto: OtpVerifyDto) {
return this.authService.verifyOtp(dto.phone, dto.code);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' })
+110 -32
View File
@@ -1,46 +1,78 @@
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { VerificationService } from '../users/verification.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { User } from '../users/entities/user.entity';
import { normalizeIranMobile } from '../common/phone.util';
/** Returned when an action needs phone verification before tokens are issued. */
export interface VerificationRequired {
requiresVerification: true;
phone: string; // masked
}
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private verificationService: VerificationService,
private jwtService: JwtService,
private configService: ConfigService,
) {}
async register(registerDto: RegisterDto) {
const existingUser = await this.usersService.findByEmail(registerDto.email);
if (existingUser) {
throw new ConflictException('Email already registered');
/**
* Create an account from a mobile number. Email is optional contact info only.
* The account starts unverified; a LOGIN OTP is sent and must be confirmed via
* `verifyOtp` before tokens are issued.
*/
async register(registerDto: RegisterDto): Promise<VerificationRequired> {
const phone = normalizeIranMobile(registerDto.phone);
if (!phone) {
throw new BadRequestException('Invalid mobile number');
}
if (await this.usersService.findByPhone(phone)) {
throw new ConflictException('Mobile number already registered');
}
const email = registerDto.email?.trim().toLowerCase() || null;
if (email && (await this.usersService.findByEmail(email))) {
throw new ConflictException('Email already in use');
}
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
const user = await this.usersService.create({
...registerDto,
phone,
email,
firstName: registerDto.firstName,
lastName: registerDto.lastName,
password: hashedPassword,
phoneVerified: false,
});
const tokens = await this.generateTokens(user.id, user.email, user.role);
return {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
...tokens,
};
const { destination } = await this.verificationService.issueLoginOtp(user);
return { requiresVerification: true, phone: destination };
}
/**
* Password login by mobile. If the phone isn't verified yet, an OTP is sent and
* verification is required to finish.
*/
async login(loginDto: LoginDto) {
const user = await this.usersService.findByEmail(loginDto.email);
const phone = normalizeIranMobile(loginDto.phone);
if (!phone) {
throw new UnauthorizedException('Invalid credentials');
}
const user = await this.usersService.findByPhone(phone);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
@@ -54,17 +86,46 @@ export class AuthService {
throw new UnauthorizedException('Account is deactivated');
}
const tokens = await this.generateTokens(user.id, user.email, user.role);
return {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
...tokens,
};
if (!user.phoneVerified) {
const { destination } = await this.verificationService.issueLoginOtp(user);
return { requiresVerification: true, phone: destination } as VerificationRequired;
}
const tokens = await this.generateTokens(user);
return { user: this.publicUser(user), ...tokens };
}
/**
* Passwordless login: send a one-time code to a registered phone. Returns a
* generic response regardless of whether the phone exists (no enumeration).
*/
async requestOtp(rawPhone: string) {
const phone = normalizeIranMobile(rawPhone);
if (phone) {
const user = await this.usersService.findByPhone(phone);
if (user && user.isActive) {
await this.verificationService.issueLoginOtp(user);
}
}
return { sent: true };
}
/** Verify a LOGIN OTP (registration completion or passwordless login). */
async verifyOtp(rawPhone: string, code: string) {
const phone = normalizeIranMobile(rawPhone);
if (!phone) {
throw new BadRequestException('Invalid mobile number');
}
const user = await this.usersService.findByPhone(phone);
if (!user || !user.isActive) {
throw new UnauthorizedException('Invalid credentials');
}
await this.verificationService.verifyLoginOtp(user, code);
const fresh = (await this.usersService.findById(user.id)) ?? user;
const tokens = await this.generateTokens(fresh);
return { user: this.publicUser(fresh), ...tokens };
}
async refreshToken(refreshToken: string) {
@@ -78,14 +139,31 @@ export class AuthService {
throw new UnauthorizedException();
}
return this.generateTokens(user.id, user.email, user.role);
return this.generateTokens(user);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
private async generateTokens(userId: string, email: string, role: string) {
const payload = { sub: userId, email, role };
private publicUser(user: User) {
return {
id: user.id,
phone: user.phone,
email: user.email,
phoneVerified: user.phoneVerified,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
};
}
private async generateTokens(user: User) {
// `email` may be null; use phone as the human-readable identity claim.
const payload = {
sub: user.id,
email: user.email ?? user.phone,
role: user.role,
};
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload),
+4 -4
View File
@@ -1,10 +1,10 @@
import { IsEmail, IsString } from 'class-validator';
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: '09121234567' })
@IsString()
phone: string;
@ApiProperty({ example: 'SecureP@ss123' })
@IsString()
+20
View File
@@ -0,0 +1,20 @@
import { IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class OtpRequestDto {
@ApiProperty({ example: '09121234567' })
@IsString()
phone: string;
}
export class OtpVerifyDto {
@ApiProperty({ example: '09121234567' })
@IsString()
phone: string;
@ApiProperty({ example: '123456' })
@IsString()
@MinLength(4)
@MaxLength(8)
code: string;
}
+16 -5
View File
@@ -1,10 +1,16 @@
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsString,
MinLength,
MaxLength,
IsOptional,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: '09121234567' })
@IsString()
phone: string;
@ApiProperty({ example: 'SecureP@ss123' })
@IsString()
@@ -23,4 +29,9 @@ export class RegisterDto {
@MinLength(1)
@MaxLength(50)
lastName: string;
@ApiPropertyOptional({ example: 'john@example.com' })
@IsOptional()
@IsEmail()
email?: string;
}
+8
View File
@@ -7,6 +7,14 @@ export enum UserRole {
SALES = 'sales',
}
/** What a one-time SMS code authorises. */
export enum VerificationPurpose {
/** Verify phone ownership for registration completion and passwordless login. */
LOGIN = 'login',
/** Verify a new phone number when changing it from the account page. */
CHANGE_PHONE = 'change_phone',
}
export enum TicketDepartment {
TECHNICAL = 'technical',
SALES = 'sales',
+53
View File
@@ -0,0 +1,53 @@
/**
* Iranian mobile number helpers.
*
* Canonical storage form is E.164: `+989XXXXXXXXX` (13 chars).
* Inputs are accepted liberally (Persian/Arabic digits, spaces, common
* prefixes) and normalised to that single canonical form so the same number
* can never be stored twice under different spellings.
*/
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
function toLatinDigits(input: string): string {
return input.replace(/[۰-۹٠-٩]/g, (ch) => {
const p = PERSIAN_DIGITS.indexOf(ch);
if (p > -1) return String(p);
const a = ARABIC_DIGITS.indexOf(ch);
if (a > -1) return String(a);
return ch;
});
}
/**
* Normalise any reasonable Iranian mobile spelling to canonical `+989XXXXXXXXX`.
* Returns `null` when the input is not a valid Iranian mobile number.
*/
export function normalizeIranMobile(raw: string | null | undefined): string | null {
if (!raw) return null;
let s = toLatinDigits(String(raw)).replace(/[\s\-()]/g, '');
// Strip international/access prefixes down to the national significant number.
if (s.startsWith('+98')) s = s.slice(3);
else if (s.startsWith('0098')) s = s.slice(4);
else if (s.startsWith('98') && s.length === 12) s = s.slice(2);
else if (s.startsWith('0')) s = s.slice(1);
// National significant number for Iranian mobiles is `9XXXXXXXXX` (10 digits).
if (!/^9\d{9}$/.test(s)) return null;
return `+98${s}`;
}
export function isValidIranMobile(raw: string | null | undefined): boolean {
return normalizeIranMobile(raw) !== null;
}
/**
* Kavenegar's `receptor` expects the local `09XXXXXXXXX` form rather than E.164.
*/
export function toLocalMobile(e164: string): string {
const normalized = normalizeIranMobile(e164);
if (!normalized) return e164;
return `0${normalized.slice(3)}`;
}
+6
View File
@@ -84,6 +84,12 @@ export default () => ({
port: parseInt(process.env.REDIS_PORT || '6379', 10),
},
// OTP SMS via Kavenegar (verify/lookup template API).
sms: {
kavenegarApiKey: process.env.KAVENEGAR_API_KEY || '',
kavenegarOtpTemplate: process.env.KAVENEGAR_OTP_TEMPLATE || '',
},
registry: {
/** In-cluster registry — Kaniko push and app image pull (same host). */
url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000',
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class NotificationsModule {}
+64
View File
@@ -0,0 +1,64 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { toLocalMobile } from '../common/phone.util';
/**
* SMS delivery via Kavenegar's Verify Lookup API (OTP templates).
* https://kavenegar.com/rest.html#sms-lookup
*
* When credentials are absent we fall back to logging the code in development
* so the flow stays testable; in production a missing config is a hard error.
*/
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly config: ConfigService) {}
private get apiKey(): string | undefined {
return this.config.get<string>('sms.kavenegarApiKey') || undefined;
}
private get template(): string | undefined {
return this.config.get<string>('sms.kavenegarOtpTemplate') || undefined;
}
isConfigured(): boolean {
return Boolean(this.apiKey && this.template);
}
async sendOtp(phoneE164: string, code: string): Promise<void> {
if (!this.isConfigured()) {
if (this.config.get('nodeEnv') !== 'production') {
this.logger.warn(
`[DEV] SMS not configured — OTP for ${phoneE164} is ${code}`,
);
return;
}
throw new ServiceUnavailableException('SMS delivery is not configured');
}
const receptor = toLocalMobile(phoneE164);
const url =
`https://api.kavenegar.com/v1/${this.apiKey}/verify/lookup.json` +
`?receptor=${encodeURIComponent(receptor)}` +
`&token=${encodeURIComponent(code)}` +
`&template=${encodeURIComponent(this.template!)}`;
try {
const res = await fetch(url, { method: 'GET' });
const body: any = await res.json().catch(() => null);
const status = body?.return?.status;
if (!res.ok || status !== 200) {
this.logger.error(
`Kavenegar OTP send failed (${status ?? res.status}): ${body?.return?.message ?? ''}`,
);
throw new ServiceUnavailableException('Failed to send SMS code');
}
} catch (err) {
if (err instanceof ServiceUnavailableException) throw err;
this.logger.error(`Kavenegar request error: ${(err as Error).message}`);
throw new ServiceUnavailableException('Failed to send SMS code');
}
}
}
+16 -3
View File
@@ -6,6 +6,7 @@ import { AppModule } from './app.module';
import { UsersService } from './users/users.service';
import { PlatformSetting } from './billing/entities/platform-setting.entity';
import { UserRole } from './common/enums';
import { normalizeIranMobile } from './common/phone.util';
/**
* Seed script — creates the initial super admin user.
@@ -26,18 +27,29 @@ async function bootstrap() {
const email = process.env.ADMIN_EMAIL || 'test@example.com';
const password = process.env.ADMIN_PASSWORD || 'Admin123!';
// Login is mobile-only, so the admin needs a verified phone to sign in.
const phone = normalizeIranMobile(process.env.ADMIN_PHONE || '09120000000');
const existing = await usersService.findByEmail(email);
if (existing) {
console.log(`⚠️ Admin user already exists: ${email} (role: ${existing.role})`);
if (existing.role !== UserRole.ADMIN) {
await usersService.update(existing.id, { role: UserRole.ADMIN });
console.log(`✅ Promoted ${email} to admin`);
const patch: any = {};
if (existing.role !== UserRole.ADMIN) patch.role = UserRole.ADMIN;
// Ensure the admin has a verified phone for mobile login.
if (!existing.phone && phone) {
patch.phone = phone;
patch.phoneVerified = true;
}
if (Object.keys(patch).length) {
await usersService.update(existing.id, patch);
console.log(`✅ Updated admin ${email}: ${Object.keys(patch).join(', ')}`);
}
} else {
const hashedPassword = await bcrypt.hash(password, 12);
await usersService.create({
email,
phone,
phoneVerified: true,
password: hashedPassword,
firstName: 'Super',
lastName: 'Admin',
@@ -47,6 +59,7 @@ async function bootstrap() {
}
console.log(`\n📋 Admin credentials:`);
console.log(` Mobile: ${phone}`);
console.log(` Email: ${email}`);
console.log(` Password: ${password}`);
+12 -2
View File
@@ -14,8 +14,18 @@ export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
// Phone is the login identifier (required at registration). Email is an
// optional contact field only — it is never used to authenticate.
// Both are unique; Postgres treats NULLs as distinct, so accounts missing
// either coexist freely.
@Column({ unique: true, nullable: true })
phone: string | null; // canonical E.164, e.g. +989121234567
@Column({ unique: true, nullable: true })
email: string | null;
@Column({ default: false })
phoneVerified: boolean;
@Column()
password: string;
@@ -0,0 +1,54 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { VerificationPurpose } from '../../common/enums';
import { User } from './user.entity';
/**
* A short-lived one-time SMS code used to prove ownership of a phone number,
* for registration/login verification and for changing the bound number.
* The code itself is stored only as a hash; `destination` holds the phone
* (E.164) the code was sent to.
*/
@Entity('verification_codes')
@Index(['userId', 'purpose'])
export class VerificationCode {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column()
userId: string;
@Column({ type: 'enum', enum: VerificationPurpose })
purpose: VerificationPurpose;
/** The phone (E.164) this code was sent to / authorises binding. */
@Column()
destination: string;
/** bcrypt hash of the numeric code — never store the plaintext OTP. */
@Column()
codeHash: string;
@Column({ type: 'timestamptz' })
expiresAt: Date;
@Column({ type: 'int', default: 0 })
attempts: number;
@Column({ type: 'timestamptz', nullable: true })
consumedAt: Date | null;
@CreateDateColumn()
createdAt: Date;
}
+90 -1
View File
@@ -13,6 +13,7 @@ import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from 'class-validator';
import { UsersService } from './users.service';
import { VerificationService } from './verification.service';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
@@ -44,6 +45,51 @@ class UpdateRoleDto {
role: UserRole;
}
class UpdateProfileDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(50)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(50)
lastName?: string;
// Optional contact email (not a login identifier). Empty string clears it.
@IsOptional()
@IsString()
@MaxLength(120)
email?: string;
}
class ChangePasswordDto {
@IsString()
currentPassword: string;
@IsString()
@MinLength(8)
@MaxLength(64)
newPassword: string;
}
class RequestPhoneChangeDto {
@IsString()
phone: string;
@IsString()
currentPassword: string;
}
class ConfirmCodeDto {
@IsString()
@MinLength(4)
@MaxLength(8)
code: string;
}
class AdminSetPasswordDto {
@IsString()
@MinLength(8)
@@ -56,7 +102,10 @@ class AdminSetPasswordDto {
@Controller('users')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
constructor(
private readonly usersService: UsersService,
private readonly verificationService: VerificationService,
) {}
@Get('me')
@ApiOperation({ summary: 'Get current user profile' })
@@ -69,6 +118,46 @@ export class UsersController {
return null;
}
@Patch('me')
@ApiOperation({ summary: 'Update own name' })
async updateProfile(@Request() req: any, @Body() dto: UpdateProfileDto) {
const user = await this.usersService.updateProfile(req.user.id, dto);
const { password, ...result } = user;
return result;
}
@Post('me/password')
@ApiOperation({ summary: 'Change own password (requires current password)' })
async changePassword(@Request() req: any, @Body() dto: ChangePasswordDto) {
await this.usersService.changeOwnPassword(
req.user.id,
dto.currentPassword,
dto.newPassword,
);
return { message: 'Password updated' };
}
@Post('me/phone/request')
@ApiOperation({
summary: 'Start phone change — sends an OTP to the new number',
})
async requestPhoneChange(
@Request() req: any,
@Body() dto: RequestPhoneChangeDto,
) {
return this.verificationService.requestPhoneChange(
req.user.id,
dto.phone,
dto.currentPassword,
);
}
@Post('me/phone/confirm')
@ApiOperation({ summary: 'Confirm phone change with the OTP' })
async confirmPhoneChange(@Request() req: any, @Body() dto: ConfirmCodeDto) {
return this.verificationService.confirmPhoneChange(req.user.id, dto.code);
}
@Get()
@Roles(UserRole.ADMIN, UserRole.TECHNICAL, UserRole.SALES)
@ApiOperation({ summary: 'List all users with optional search (Staff/Admin)' })
+9 -3
View File
@@ -1,13 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { VerificationService } from './verification.service';
import { UsersController } from './users.controller';
import { User } from './entities/user.entity';
import { VerificationCode } from './entities/verification-code.entity';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([User])],
imports: [
TypeOrmModule.forFeature([User, VerificationCode]),
NotificationsModule,
],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
providers: [UsersService, VerificationService],
exports: [UsersService, VerificationService],
})
export class UsersModule {}
+68 -1
View File
@@ -3,6 +3,8 @@ import {
NotFoundException,
ConflictException,
ForbiddenException,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, ILike } from 'typeorm';
@@ -50,13 +52,32 @@ export class UsersService {
}
async findByEmail(email: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { email } });
return this.usersRepository.findOne({ where: { email: email.toLowerCase() } });
}
async findByPhone(phone: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { phone } });
}
/** Login lookup: match against whichever identifier was supplied. */
async findByEmailOrPhone(opts: {
email?: string;
phone?: string;
}): Promise<User | null> {
if (opts.email) return this.findByEmail(opts.email);
if (opts.phone) return this.findByPhone(opts.phone);
return null;
}
async findById(id: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { id } });
}
/** True when `plain` matches the user's stored password hash. */
async verifyPassword(user: User, plain: string): Promise<boolean> {
return bcrypt.compare(plain, user.password);
}
async findAll(search?: string): Promise<any[]> {
const where = search
? [
@@ -104,6 +125,52 @@ export class UsersService {
return this.usersRepository.save(user);
}
/**
* Self-service profile update. Email is an optional contact field (not a login
* identifier) so it is set directly here; uniqueness is still enforced. Pass
* `email: null` or '' to clear it.
*/
async updateProfile(
id: string,
data: { firstName?: string; lastName?: string; email?: string | null },
): Promise<User> {
const patch: Partial<User> = {};
if (data.firstName !== undefined) patch.firstName = data.firstName;
if (data.lastName !== undefined) patch.lastName = data.lastName;
if (data.email !== undefined) {
const email = data.email ? data.email.trim().toLowerCase() : null;
if (email) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new BadRequestException('Invalid email address');
}
const existing = await this.findByEmail(email);
if (existing && existing.id !== id) {
throw new ConflictException('Email already in use');
}
}
patch.email = email;
}
return this.update(id, patch);
}
/** Self-service password change — requires the current password. */
async changeOwnPassword(
id: string,
currentPassword: string,
newPassword: string,
): Promise<void> {
const user = await this.findById(id);
if (!user) {
throw new NotFoundException('User not found');
}
const ok = await bcrypt.compare(currentPassword, user.password);
if (!ok) {
throw new UnauthorizedException('Current password is incorrect');
}
user.password = await bcrypt.hash(newPassword, 12);
await this.usersRepository.save(user);
}
async updateRole(id: string, role: UserRole): Promise<void> {
const user = await this.findById(id);
if (!user) {
+209
View File
@@ -0,0 +1,209 @@
import {
Injectable,
BadRequestException,
ConflictException,
UnauthorizedException,
NotFoundException,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, LessThan, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { VerificationCode } from './entities/verification-code.entity';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { SmsService } from '../notifications/sms.service';
import { VerificationPurpose } from '../common/enums';
import { normalizeIranMobile } from '../common/phone.util';
const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const RESEND_COOLDOWN_MS = 60 * 1000; // 60 seconds between requests
const MAX_VERIFY_ATTEMPTS = 5;
/**
* One-time SMS code flows for phone ownership.
*
* Policy (best practice):
* - Codes are 6 digits, stored hashed, expire in 5 minutes, allow 5 attempts,
* and are rate-limited to one request per minute per (user, purpose).
* - LOGIN codes verify a phone for registration completion and passwordless
* login; CHANGE_PHONE codes verify a new number from the account page (which
* additionally re-authenticates with the current password).
*/
@Injectable()
export class VerificationService {
constructor(
@InjectRepository(VerificationCode)
private readonly codeRepo: Repository<VerificationCode>,
private readonly usersService: UsersService,
private readonly smsService: SmsService,
) {}
// ── Login / registration phone verification ───────────
/** Send a LOGIN code to the user's bound phone. */
async issueLoginOtp(user: User) {
if (!user.phone) {
throw new BadRequestException('Account has no mobile number');
}
return this.issue({
userId: user.id,
purpose: VerificationPurpose.LOGIN,
destination: user.phone,
});
}
/** Verify a LOGIN code; marks the phone verified on success. */
async verifyLoginOtp(user: User, code: string) {
await this.consume(user.id, VerificationPurpose.LOGIN, code);
if (!user.phoneVerified) {
await this.usersService.update(user.id, { phoneVerified: true });
}
}
// ── Account-page phone change ─────────────────────────
async requestPhoneChange(
userId: string,
rawPhone: string,
currentPassword: string,
) {
const phone = normalizeIranMobile(rawPhone);
if (!phone) {
throw new BadRequestException('Invalid mobile number');
}
await this.assertPassword(userId, currentPassword);
const existing = await this.usersService.findByPhone(phone);
if (existing && existing.id !== userId) {
throw new ConflictException('Mobile number already in use');
}
return this.issue({
userId,
purpose: VerificationPurpose.CHANGE_PHONE,
destination: phone,
});
}
async confirmPhoneChange(userId: string, code: string) {
const record = await this.consume(userId, VerificationPurpose.CHANGE_PHONE, code);
// Re-check uniqueness at confirm time to close the request→confirm race.
const clash = await this.usersService.findByPhone(record.destination);
if (clash && clash.id !== userId) {
throw new ConflictException('Mobile number already in use');
}
await this.usersService.update(userId, {
phone: record.destination,
phoneVerified: true,
});
const user = await this.usersService.findById(userId);
if (!user) throw new NotFoundException('User not found');
const { password, ...result } = user;
return result;
}
// ── internals ─────────────────────────────────────────
private async assertPassword(userId: string, password: string) {
const user = await this.usersService.findById(userId);
if (!user) throw new NotFoundException('User not found');
const ok = await this.usersService.verifyPassword(user, password);
if (!ok) throw new UnauthorizedException('Current password is incorrect');
}
private async issue(opts: {
userId: string;
purpose: VerificationPurpose;
destination: string;
}) {
const { userId, purpose, destination } = opts;
// Rate-limit: refuse if a code for this purpose was issued < cooldown ago.
const recent = await this.codeRepo.findOne({
where: { userId, purpose, consumedAt: IsNull() },
order: { createdAt: 'DESC' },
});
if (
recent &&
Date.now() - recent.createdAt.getTime() < RESEND_COOLDOWN_MS &&
recent.expiresAt.getTime() > Date.now()
) {
throw new HttpException(
'Please wait before requesting another code',
HttpStatus.TOO_MANY_REQUESTS,
);
}
// Invalidate any prior unconsumed codes for this purpose.
await this.codeRepo.update(
{ userId, purpose, consumedAt: IsNull() },
{ consumedAt: new Date() },
);
const code = String(Math.floor(100000 + Math.random() * 900000)); // 6 digits
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
const record = this.codeRepo.create({
userId,
purpose,
destination,
codeHash: await bcrypt.hash(code, 10),
expiresAt,
attempts: 0,
});
await this.codeRepo.save(record);
await this.smsService.sendOtp(destination, code);
return { destination: this.maskPhone(destination), expiresAt };
}
/** Validate and consume the latest active code for (user, purpose). */
private async consume(
userId: string,
purpose: VerificationPurpose,
code: string,
): Promise<VerificationCode> {
const record = await this.codeRepo.findOne({
where: { userId, purpose, consumedAt: IsNull() },
order: { createdAt: 'DESC' },
});
if (!record || record.expiresAt.getTime() < Date.now()) {
throw new BadRequestException('No active code — request a new one');
}
if (record.attempts >= MAX_VERIFY_ATTEMPTS) {
record.consumedAt = new Date();
await this.codeRepo.save(record);
throw new BadRequestException('Too many attempts — request a new code');
}
const ok = await bcrypt.compare(code, record.codeHash);
if (!ok) {
record.attempts += 1;
await this.codeRepo.save(record);
throw new BadRequestException('Invalid code');
}
record.consumedAt = new Date();
await this.codeRepo.save(record);
return record;
}
/** Best-effort cleanup of long-expired codes (called opportunistically). */
async purgeExpired(): Promise<void> {
await this.codeRepo.delete({
expiresAt: LessThan(new Date(Date.now() - 24 * 60 * 60 * 1000)),
});
}
// +989121234567 -> +9891****4567
private maskPhone(value: string): string {
return value.length > 8
? `${value.slice(0, 5)}****${value.slice(-4)}`
: value;
}
}