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

@@ -3,3 +3,4 @@ export const ROLE_KEY = '__ROLE_KEY__';
export const ORG_ROLE_KEY = '__ORG_ROLE_KEY__'
export const ORG_ROLES_ALL_KEY = '__ORG_ROLE_ALL_KEY__';
export const CAN_PERFORM_KEY = '__CAN_PERFORM_KEY__';
export const TEMP_TOKEN_KEY = '__TEMP_TOKEN_KEY__';

View File

@@ -10,6 +10,7 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",

View File

@@ -0,0 +1,35 @@
/*
Warnings:
- You are about to drop the column `firstName` on the `user` table. All the data in the column will be lost.
- You are about to drop the column `lastName` on the `user` table. All the data in the column will be lost.
- You are about to drop the column `middleName` on the `user` table. All the data in the column will be lost.
- You are about to drop the column `profilePicture` on the `user` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "user" DROP COLUMN "firstName",
DROP COLUMN "lastName",
DROP COLUMN "middleName",
DROP COLUMN "profilePicture";
-- CreateTable
CREATE TABLE "UserAdditionalInformation" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"middleName" TEXT,
"lastName" TEXT NOT NULL,
"profilePicture" TEXT,
"address" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserAdditionalInformation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserAdditionalInformation_userId_key" ON "UserAdditionalInformation"("userId");
-- AddForeignKey
ALTER TABLE "UserAdditionalInformation" ADD CONSTRAINT "UserAdditionalInformation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,20 @@
/*
Warnings:
- You are about to drop the column `isVerified` on the `user` table. All the data in the column will be lost.
- You are about to drop the column `generatedOn` on the `user_otp` table. All the data in the column will be lost.
- Added the required column `expiresAt` to the `user_otp` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "USER_ACCOUNT_STATUS" AS ENUM ('pending', 'active', 'suspended', 'deleted');
-- AlterTable
ALTER TABLE "user" DROP COLUMN "isVerified",
ADD COLUMN "status" "USER_ACCOUNT_STATUS" NOT NULL DEFAULT 'pending',
ALTER COLUMN "password" DROP NOT NULL;
-- AlterTable
ALTER TABLE "user_otp" DROP COLUMN "generatedOn",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL;

View File

@@ -1,14 +1,10 @@
model User {
id String @id @default(uuid())
firstName String
middleName String?
lastName String
email String @unique
password String
password String?
role USER_ROLE @default(user)
isVerified Boolean? @default(false) // TODO: Email using queue
refreshToken String?
profilePicture String?
status USER_ACCOUNT_STATUS @default(pending)
isDeleted Boolean? @default(false)
deletedAt DateTime?
@@ -17,14 +13,33 @@ model User {
organizations OrganizationUserJoinTable[]
organizationsRequested OrganizationJoinRequest[]
userAdditionalInformation UserAdditionalInformation?
@@map("user")
}
model UserAdditionalInformation {
id String @id @default(uuid())
userId String @unique
firstName String
middleName String?
lastName String
profilePicture String?
address String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model UserOTP {
email String @unique
otp Int
generatedOn DateTime
// ExipresAt is also saved so its easier to check and also
// run cron job to remove expired OTPs
createdAt DateTime @default(now())
expiresAt DateTime
@@map("user_otp")
}
@@ -33,3 +48,10 @@ enum USER_ROLE {
superadmin
user
}
enum USER_ACCOUNT_STATUS {
pending
active
suspended
deleted
}

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.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 {
@@ -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,7 +1,16 @@
import { USER_ACCOUNT_STATUS } from "prisma/generated/prisma/enums";
export interface TokenInputType {
userId: string;
email: string;
role: string;
status: USER_ACCOUNT_STATUS;
}
export interface OTPTokenInputType {
userId: string;
email: string;
status: USER_ACCOUNT_STATUS
}
export interface AccessTokenPayloadType extends TokenInputType { }

View File

@@ -1,6 +1,5 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -12,8 +11,6 @@ import {
ORGANIZATION_JOIN_REQUEST,
ORGANIZATION_JOIN_REQUEST_TYPE,
} from 'prisma/generated/prisma/enums';
import { AuthorizationService } from 'src/authorization/authorization.service';
import { USER_ORGANIZATION_OPERATIONS } from 'src/authorization/operations';
import { Prisma } from 'prisma/generated/prisma/client';
import { JoinRequestToOrganizationRequestDTO } from './dto/join-request.dto';
import { USER_ORG_ACCEPT_REJECT_ACTION } from './constants';
@@ -24,7 +21,6 @@ export class OrganizationMembershipService {
private readonly userService: UserService,
private readonly orgService: OrganizationService,
private readonly prisma: PrismaService,
private readonly authorization: AuthorizationService,
) { }
/* *
@@ -121,7 +117,12 @@ export class OrganizationMembershipService {
requestType: joinReqType,
status: ORGANIZATION_JOIN_REQUEST.PENDING
},
include: { user: { select: { firstName: true, email: true } } }
include: {
user: {
include: { userAdditionalInformation: { select: { firstName: true } } },
select: { email: true }
},
}
})
}
@@ -442,9 +443,9 @@ export class OrganizationMembershipService {
include: {
user: {
select: {
firstName: true,
email: true
}
},
include: { userAdditionalInformation: { select: { firstName: true } } },
}
},
}

View File

@@ -4,11 +4,12 @@ import {
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { PrismaClient } from 'prisma/generated/prisma/client';
import { Prisma, PrismaClient } from 'prisma/generated/prisma/client';
import { Pool } from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { RequestContextService } from 'core/als/request-context.service';
import { ConfigService } from '@nestjs/config';
import { TransactionClient } from 'prisma/generated/prisma/internal/prismaNamespace';
@Global()
@Injectable()
@@ -49,7 +50,7 @@ export class PrismaService
* For shared transaction across services. If present, return the transaction.
* Else returns itself.
* */
get client() {
return this.ctx.get().tx ?? this;
get client(): TransactionClient {
return this.ctx.tx ?? this;
}
}

View File

@@ -1,21 +1,15 @@
import { User } from 'prisma/generated/prisma/client';
import { User, USER_ACCOUNT_STATUS } from 'prisma/generated/prisma/client';
export class UserDTO {
readonly id: string;
readonly email: string;
readonly firstName: string;
readonly middleName: string | null;
readonly lastName: string;
readonly role: string;
readonly profilePicture: string | null;
readonly status: USER_ACCOUNT_STATUS;
constructor(user: User) {
this.id = user.id;
this.email = user.email;
this.firstName = user.firstName;
this.lastName = user.lastName;
this.middleName = user.middleName;
this.role = user.role;
this.profilePicture = user.profilePicture;
this.status = user.status
}
}

View File

@@ -1,16 +1,50 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from 'prisma/generated/prisma/client';
import { RegisterUserRequestDTO } from 'src/auth/dto';
import { Prisma, USER_ACCOUNT_STATUS } from 'prisma/generated/prisma/client';
import { CompleteProfileSetupRequestDTO } from 'src/auth/dto';
import { PrismaService } from 'src/prisma/prisma.service';
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) { }
constructor(
private readonly prisma: PrismaService,
) { }
async createUserWithPassword(dto: RegisterUserRequestDTO) {
async initializeUserWithEmail(email: string) {
try {
return await this.prisma.user.create({
data: dto,
return await this.prisma.client.user.create({
data: {
email,
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') {
throw new ConflictException('User already exists');
}
} else throw err;
}
}
async createUserWithPassword(email: string, password: string) {
return await this.prisma.client.user.update({
where: {
email,
},
data: {
password,
status: USER_ACCOUNT_STATUS.active
},
});
}
async createUserAdditionalInformation(userId: string, dto: CompleteProfileSetupRequestDTO) {
const { password, ...rest } = dto;
try {
return await this.prisma.client.userAdditionalInformation.create({
data: {
userId,
...rest
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
@@ -37,7 +71,8 @@ export class UserService {
}
async findByEmail(email: string) {
return await this.prisma.user.findUnique({
const client = this.prisma.client.user ?? this.prisma.user;
return await client.findUnique({
where: {
email: email,
},
@@ -55,7 +90,8 @@ export class UserService {
* USER OTP SERVICES
* */
async findByEmailInOTP(email: string) {
return await this.prisma.userOTP.findUnique({
const client = this.prisma.client.userOTP ?? this.prisma.userOTP;
return await client.findUnique({
where: {
email,
},
@@ -63,7 +99,7 @@ export class UserService {
}
async removeByEmailInOTP(email: string) {
return await this.prisma.userOTP.delete({
return await this.prisma.client.userOTP.delete({
where: {
email
}
@@ -71,18 +107,20 @@ export class UserService {
}
async updateOTPByEmail(email: string, otp: number) {
return await this.prisma.userOTP.upsert({
return await this.prisma.client.userOTP.upsert({
where: {
email
},
create: {
email,
otp,
generatedOn: new Date()
createdAt: new Date(),
expiresAt: new Date(Date.now() + 5 * 60 * 1000)
},
update: {
otp,
generatedOn: new Date()
createdAt: new Date(),
expiresAt: new Date(Date.now() + 5 * 60 * 1000)
}
})
}