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:
@@ -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