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:
@@ -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' })
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)' })
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user