feat: Change auth flow

This commit is contained in:
SauravDhakal
2026-04-11 07:57:28 +05:45
parent ab8b2ef353
commit aa8deadf1f
21 changed files with 442 additions and 123 deletions

View File

@@ -5,17 +5,21 @@ import {
HttpStatus,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { ApiOperation } from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import {
CompleteProfileSetupRequestDTO,
LoginUserRequestDTO,
LoginUserResponseDTO,
RegisterUserRequestDTO,
ValidateUserRegisterOTPRequestDTO,
} from './dto';
import { Response } from 'express';
import { DataResponse } from 'common/http';
import { Public } from './decorators';
import { IsTempToken, Public } from './decorators';
import { AuthGuard } from './guards/auth.guard';
@Controller('auth')
@Public()
@@ -41,7 +45,6 @@ export class AuthController {
}
@ApiOperation({ summary: 'User register' })
@HttpCode(HttpStatus.CREATED)
@Post('/register')
async register(@Body() body: RegisterUserRequestDTO): Promise<string> {
await this.authService.register(body);
@@ -49,6 +52,36 @@ export class AuthController {
return 'Check your email for OTP';
}
// TODO: Assign Temp Token
@ApiOperation({ summary: 'Validate OTP' })
@Post('/validate-otp')
async validateOTP(@Body() body: ValidateUserRegisterOTPRequestDTO) {
const { accessToken, refreshToken } = await this.authService.validateOtp(body);
return {
message: 'Continue with the rest of profile setup',
data: {
accessToken,
refreshToken
}
}
}
// TODO: Assign Temp Token
@ApiOperation({ summary: 'Complete Profile' })
@ApiBearerAuth("access-token")
@Public(false)
@IsTempToken()
@UseGuards(AuthGuard)
@Post('/complete-profile')
async completeUserProfile(@Body() body: CompleteProfileSetupRequestDTO): Promise<string> {
await this.authService.completeProfileSetup(body);
return 'Welcome';
}
logout() { }
forgotPassword() { }

View File

@@ -7,6 +7,7 @@ import { UserModule } from 'src/user/user.module';
import { JwtModule } from '@nestjs/jwt';
import { RequestContextModule } from 'core/als/request-context.module';
import { BullModule } from '@nestjs/bullmq';
import { PrismaModule } from 'src/prisma/prisma.module';
@Global()
@Module({
@@ -25,6 +26,7 @@ import { BullModule } from '@nestjs/bullmq';
UserModule,
JwtModule,
RequestContextModule,
PrismaModule
],
})
export class AuthModule { }

View File

@@ -1,12 +1,25 @@
import { ConflictException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
InternalServerErrorException,
UnauthorizedException
} from '@nestjs/common';
import { Public } from './decorators';
import { LoginUserRequestDTO, RegisterUserRequestDTO } from './dto';
import {
CompleteProfileSetupRequestDTO,
LoginUserRequestDTO,
RegisterUserRequestDTO,
ValidateUserRegisterOTPRequestDTO
} from './dto';
import * as bcrypt from 'bcrypt';
import { UserService } from 'src/user/user.service';
import { TokenInputType } from './types';
import { OTPTokenInputType, TokenInputType } from './types';
import { JwtService } from '@nestjs/jwt';
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import { PrismaService } from 'src/prisma/prisma.service';
import { RequestContextService } from 'core/als/request-context.service';
@Injectable()
@Public()
@@ -14,6 +27,8 @@ export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly requestContext: RequestContextService,
@InjectQueue('mail') private readonly mailQueue: Queue
) { }
@@ -32,16 +47,19 @@ export class AuthService {
* Else, do nothing
* */
const now = Number(new Date()) / 1000;
const generatedOn = Number(otpExists.generatedOn) / 1000;
const generatedPlusTwoMin = (Number(otpExists.createdAt) / 1000) + 60 * 2;
if (generatedOn + (60 * 2) > now) {
if (generatedPlusTwoMin > now) {
return;
}
}
const otp = this.genOtp()
await this.userService.updateOTPByEmail(dto.email, otp);
await this.prismaService.$transaction(async (tx) => {
this.requestContext.tx = tx;
await this.userService.updateOTPByEmail(dto.email, otp);
})
this.mailQueue.add('send-register-otp-email', {
email: dto.email,
@@ -57,40 +75,120 @@ export class AuthService {
})
return true;
// const hashedPassword = await bcrypt.hash(dto.password, 10);
// await this.userService.createUserWithPassword({
// ...dto,
// password: hashedPassword,
// });
//
// this.mailQueue.add('send-welcome-email', {
// email: dto.email
// }, {
// attempts: 3,
// backoff: {
// type: "exponential",
// delay: 3000,
// },
// removeOnComplete: true, // clean up Redis after success
// removeOnFail: false,
// })
//
// return true;
}
// Validate OTP
async validateOtp() {
async validateOtp(dto: ValidateUserRegisterOTPRequestDTO) {
const otpExists = await this.userService.findByEmailInOTP(dto.email)
const now = Number(new Date()) / 1000;
if (!otpExists)
throw new BadRequestException("No user found")
else if (otpExists.otp !== dto.otp)
throw new BadRequestException("Invalid OTP")
else if ((Number(otpExists.expiresAt) / 1000 < now)) {
await this.userService.removeByEmailInOTP(dto.email);
throw new BadRequestException("OTP has expired")
}
const user = await this.prismaService.$transaction(async (tx) => {
this.requestContext.tx = tx;
await this.userService.removeByEmailInOTP(dto.email);
return await this.userService.initializeUserWithEmail(dto.email)
})
if (!user)
throw new InternalServerErrorException()
const token = {
userId: user.id,
email: user.email,
status: user.status
}
const tokens = this.genSignedTempToken(token)
return tokens;
}
// Complete rest of singup process
async completeSignup() {
async completeProfileSetup(dto: CompleteProfileSetupRequestDTO) {
const user = this.requestContext.user;
if (!user)
throw new UnauthorizedException("User")
const hashedPassword = await bcrypt.hash(dto.password, 10);
const {
newUser,
userAdditionalInfo: _
} = await this.prismaService.$transaction(async (tx) => {
this.requestContext.tx = tx;
const newUser = await this.userService.createUserWithPassword(
user.email,
hashedPassword,
);
if (!newUser)
throw new UnauthorizedException()
const userAdditionalInfo = await this.userService.createUserAdditionalInformation(
newUser?.id,
dto
)
return {
newUser,
userAdditionalInfo,
}
})
this.mailQueue.add('send-welcome-email', {
email: user.email,
}, {
attempts: 3,
backoff: {
type: "exponential",
delay: 3000,
},
removeOnComplete: true, // clean up Redis after success
removeOnFail: false,
})
const token = {
userId: newUser.id,
email: newUser.email,
role: newUser.role,
status: newUser.status
};
const {
accessToken,
refreshToken
} = await this.genSignedTokens(token)
return {
accessToken,
refreshToken,
user: newUser
};
}
async login(dto: LoginUserRequestDTO) {
const user = await this.userService.findUserForAuth(dto.email);
if (!user) throw new UnauthorizedException('Invalid credentials.');
else if (!user.password) {
const token = {
userId: user.id,
email: user.email,
status: user.status
}
const { accessToken, refreshToken } = await this.genSignedTempToken(token)
return { accessToken, refreshToken, user };
}
const passwordMatch = await bcrypt.compare(dto.password, user.password);
if (!passwordMatch) throw new UnauthorizedException('Invalid credentials.');
@@ -99,6 +197,7 @@ export class AuthService {
userId: user.id,
email: user.email,
role: user.role,
status: user.status
};
// TODO: Store more info: orgId, orgRole, etc
@@ -136,6 +235,23 @@ export class AuthService {
return { accessToken, refreshToken };
}
private async genSignedTempToken(token: OTPTokenInputType) {
const accessToken = await this.jwtService.signAsync(token, {
secret: 'demo',
});
const refreshToken = await this.jwtService.signAsync(
{
userId: token.userId,
},
{
secret: 'demo',
},
);
return { accessToken, refreshToken };
}
private genOtp() {
return 123456;
}

View File

@@ -1,3 +1,4 @@
export * from './public.decorator';
export * from './role.decorator';
export * from './authorization.decorator';
export * from './isTemp.decorator'

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
import { TEMP_TOKEN_KEY } from "common/keys";
export const IsTempToken = () => SetMetadata(TEMP_TOKEN_KEY, true)

View File

@@ -0,0 +1,42 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, MinLength } from "class-validator";
export class CompleteProfileSetupRequestDTO {
@ApiProperty({
description: "User's firstName",
example: 'John',
type: 'string',
})
@IsString()
@IsNotEmpty()
firstName: string;
@ApiPropertyOptional({
description: "User's middleName",
example: 'Kumar',
type: 'string',
})
@IsString()
@IsOptional()
middleName?: string;
@ApiProperty({
description: "User's lastName",
example: 'Doe',
type: 'string',
})
@IsString()
@IsNotEmpty()
lastName: string;
@ApiProperty({
description: "User's password",
example: '123456',
type: 'string',
minLength: 6,
})
@IsString()
@IsNotEmpty()
@MinLength(6)
password: string;
}

View File

@@ -1,3 +1,5 @@
export * from './register-user.dto';
export * from './login-user.dto';
export * from './login-response.dto';
export * from "./validate-otp.dto";
export * from "./complete-setup.dto";

View File

@@ -2,39 +2,9 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
export class RegisterUserRequestDTO {
@ApiProperty({
description: "User's firstName",
example: 'John',
type: 'string',
})
@IsString()
@IsNotEmpty()
firstName: string;
@ApiPropertyOptional({
description: "User's middleName",
example: 'Kumar',
type: 'string',
})
@IsString()
@IsOptional()
middleName?: string;
@ApiProperty({
description: "User's lastName",
example: 'Doe',
type: 'string',
})
@IsString()
@IsNotEmpty()
lastName: string;
@ApiProperty({
description: "User's email",
example: 'user@example.com',
@@ -43,15 +13,4 @@ export class RegisterUserRequestDTO {
@IsEmail()
@IsNotEmpty()
email: string;
@ApiProperty({
description: "User's password",
example: '123456',
type: 'string',
minLength: 6,
})
@IsString()
@IsNotEmpty()
@MinLength(6)
password: string;
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, IsNumber } from "class-validator";
export class ValidateUserRegisterOTPRequestDTO {
@ApiProperty({
description: "Register OTP",
example: 123456,
type: 'number',
})
@IsNumber()
@IsNotEmpty()
otp: number
@ApiProperty({
description: "User's email",
example: 'user@example.com',
type: 'string',
})
@IsEmail()
@IsNotEmpty()
email: string;
}

View File

@@ -1,6 +1,7 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
@@ -9,8 +10,9 @@ import { JwtService } from '@nestjs/jwt';
import { JwtPayload } from '../types';
import { Request } from 'express';
import { Reflector } from '@nestjs/core';
import { PUBLIC_KEY } from 'common/keys';
import { PUBLIC_KEY, TEMP_TOKEN_KEY } from 'common/keys';
import { UserService } from 'src/user/user.service';
import { USER_ACCOUNT_STATUS } from 'prisma/generated/prisma/enums';
@Injectable()
export class AuthGuard implements CanActivate {
@@ -19,7 +21,7 @@ export class AuthGuard implements CanActivate {
private readonly jwtService: JwtService,
private readonly requestContext: RequestContextService,
private readonly userService: UserService,
) {}
) { }
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
@@ -30,6 +32,11 @@ export class AuthGuard implements CanActivate {
);
if (isPublicRoute) return true;
const isTempToken = this.reflector.getAllAndOverride<boolean>(
TEMP_TOKEN_KEY,
[context.getHandler(), context.getClass()],
)
const token = this.extractTokenFromHeader(request);
if (!token) throw new UnauthorizedException();
@@ -38,9 +45,17 @@ export class AuthGuard implements CanActivate {
secret: 'demo',
});
// TODO: Redis + Org too, blacklist token
const userExists = await this.userService.findById(payload.userId);
if (!userExists) throw new UnauthorizedException();
// NOTE: Add more checks here (other account status)
if (userExists.status !== USER_ACCOUNT_STATUS.active) {
if (userExists.status === USER_ACCOUNT_STATUS.pending && isTempToken === undefined)
throw new ForbiddenException()
}
this.requestContext.set('user', payload);
return true;

View File

@@ -1,10 +1,11 @@
import { ORG_ROLE, USER_ROLE } from 'prisma/generated/prisma/enums';
import { ORG_ROLE, USER_ACCOUNT_STATUS, USER_ROLE } from 'prisma/generated/prisma/enums';
export interface JwtPayload {
iat?: number;
exp?: number;
orgId?: string;
orgRole?: ORG_ROLE;
status: USER_ACCOUNT_STATUS;
userId: string;
email: string;
role: USER_ROLE;

View File

@@ -1,10 +1,19 @@
import { USER_ACCOUNT_STATUS } from "prisma/generated/prisma/enums";
export interface TokenInputType {
userId: string;
email: string;
role: string;
status: USER_ACCOUNT_STATUS;
}
export interface AccessTokenPayloadType extends TokenInputType {}
export interface OTPTokenInputType {
userId: string;
email: string;
status: USER_ACCOUNT_STATUS
}
export interface AccessTokenPayloadType extends TokenInputType { }
export interface RefreshTokenPayloadType {
userId: string;