fc3d0a7e04
- Add MizbanSMS as selectable SMS provider (SMS_PROVIDER), keep Kavenegar - Distinct OTP wording per flow via OtpMessageKind (register/login/change-phone) - register() resumes an unverified account instead of blocking re-registration - Hourly cleanup of abandoned unverified accounts (>24h) + expired OTP codes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
205 lines
6.5 KiB
TypeScript
205 lines
6.5 KiB
TypeScript
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';
|
|
import { OtpMessageKind } from '../common/enums';
|
|
|
|
/** 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,
|
|
) {}
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
|
|
// An unverified account means a previous signup was never confirmed — let
|
|
// the user resume it. Only a *verified* phone is a real duplicate.
|
|
const existing = await this.usersService.findByPhone(phone);
|
|
if (existing?.phoneVerified) {
|
|
throw new ConflictException('Mobile number already registered');
|
|
}
|
|
|
|
const email = registerDto.email?.trim().toLowerCase() || null;
|
|
if (email) {
|
|
const emailOwner = await this.usersService.findByEmail(email);
|
|
if (emailOwner && emailOwner.id !== existing?.id) {
|
|
throw new ConflictException('Email already in use');
|
|
}
|
|
}
|
|
|
|
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
|
|
|
|
// Resume the stale record (overwriting name/password/email) instead of
|
|
// locking the number behind it, or create a fresh account.
|
|
const user = existing
|
|
? await this.usersService.update(existing.id, {
|
|
email,
|
|
firstName: registerDto.firstName,
|
|
lastName: registerDto.lastName,
|
|
password: hashedPassword,
|
|
})
|
|
: await this.usersService.create({
|
|
phone,
|
|
email,
|
|
firstName: registerDto.firstName,
|
|
lastName: registerDto.lastName,
|
|
password: hashedPassword,
|
|
phoneVerified: false,
|
|
});
|
|
|
|
const { destination } = await this.verificationService.issueLoginOtp(
|
|
user,
|
|
OtpMessageKind.REGISTER,
|
|
);
|
|
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 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');
|
|
}
|
|
|
|
const isPasswordValid = await bcrypt.compare(loginDto.password, user.password);
|
|
if (!isPasswordValid) {
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
|
|
if (!user.isActive) {
|
|
throw new UnauthorizedException('Account is deactivated');
|
|
}
|
|
|
|
if (!user.phoneVerified) {
|
|
// Account exists but never finished signup verification — treat as
|
|
// registration completion (welcome wording), not a passwordless login.
|
|
const { destination } = await this.verificationService.issueLoginOtp(
|
|
user,
|
|
OtpMessageKind.REGISTER,
|
|
);
|
|
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) {
|
|
try {
|
|
const payload = this.jwtService.verify(refreshToken, {
|
|
secret: this.configService.get('jwt.refreshSecret'),
|
|
});
|
|
|
|
const user = await this.usersService.findById(payload.sub);
|
|
if (!user || !user.isActive) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
return this.generateTokens(user);
|
|
} catch {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
}
|
|
|
|
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),
|
|
this.jwtService.signAsync(payload, {
|
|
secret: this.configService.get('jwt.refreshSecret'),
|
|
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
|
|
}),
|
|
]);
|
|
|
|
return { accessToken, refreshToken };
|
|
}
|
|
}
|