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
+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;
}