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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user