Add passwordless OTP login and SMS password reset.
Expose login-otp and reset-password so dashboards can finish forgot-password and one-time SMS sign-in, and sync website API docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f4295e780c
commit
08f901306c
@@ -12,8 +12,10 @@ import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { LoginOtpDto } from './dto/login-otp.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { SendOtpDto } from './dto/send-otp.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
|
||||
@@ -39,6 +41,20 @@ export class AuthController {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('login-otp')
|
||||
loginWithOtp(@Body() dto: LoginOtpDto) {
|
||||
return this.authService.loginWithOtp(dto.cellNumber, dto.code);
|
||||
}
|
||||
|
||||
@Post('reset-password')
|
||||
resetPassword(@Body() dto: ResetPasswordDto) {
|
||||
return this.authService.resetPassword(
|
||||
dto.cellNumber,
|
||||
dto.code,
|
||||
dto.newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refresh(dto.refreshToken);
|
||||
|
||||
+66
-35
@@ -307,27 +307,75 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async verifyOtp(cellNumber: string, code: string) {
|
||||
if (!this.sms.isEnabled()) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { cellNumber },
|
||||
const user = await this.consumeOtp(cellNumber, code);
|
||||
const smsEnabled = this.sms.isEnabled();
|
||||
|
||||
if (!user.cellVerifiedAt) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { cellVerifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Cell number is not registered');
|
||||
}
|
||||
return {
|
||||
enabled: smsEnabled,
|
||||
verified: true,
|
||||
message: smsEnabled
|
||||
? 'Cell number verified successfully'
|
||||
: 'SMS verification is disabled — cell number marked as verified.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.cellVerifiedAt) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { cellVerifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
async loginWithOtp(cellNumber: string, code: string) {
|
||||
const user = await this.consumeOtp(cellNumber, code);
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
verified: true,
|
||||
message: 'SMS verification is disabled — cell number marked as verified.',
|
||||
};
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
cellVerifiedAt: user.cellVerifiedAt ?? new Date(),
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const authUser = await this.getAuthUser(user.id);
|
||||
const tokens = await this.issueTokens(authUser);
|
||||
|
||||
return {
|
||||
message: 'Login successful',
|
||||
user: this.serializeUser(authUser),
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async resetPassword(cellNumber: string, code: string, newPassword: string) {
|
||||
const user = await this.consumeOtp(cellNumber, code);
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
cellVerifiedAt: user.cellVerifiedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Password reset successfully. You can now sign in with your new password.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Validates OTP (or skips when SMS is disabled) and returns the active user. */
|
||||
private async consumeOtp(cellNumber: string, code: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { cellNumber },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Cell number is not registered');
|
||||
}
|
||||
|
||||
if (!this.sms.isEnabled()) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const storedCode = await this.redis.getOtp(cellNumber);
|
||||
@@ -335,25 +383,8 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { cellNumber },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Cell number is not registered');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { cellVerifiedAt: new Date() },
|
||||
});
|
||||
await this.redis.deleteOtp(cellNumber);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
verified: true,
|
||||
message: 'Cell number verified successfully',
|
||||
};
|
||||
return user;
|
||||
}
|
||||
|
||||
private async getAuthUser(userId: bigint): Promise<AuthUser> {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class LoginOtpDto {
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||
})
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsString, Length, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||
})
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
|
||||
code!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8, { message: 'newPassword must be at least 8 characters' })
|
||||
newPassword!: string;
|
||||
}
|
||||
Reference in New Issue
Block a user