apps/recallassess/recallassess-api/src/api/client/auth/auth.service.ts
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, jwt: JwtService, config: ConfigService, eventEmitter: EventEmitter2, emailSender: BNestEmailSenderService)
|
||||||||||||||||||
|
Parameters :
|
| Private Async generateAccessToken |
generateAccessToken(participantId: number, email: string)
|
|
Returns :
Promise<string>
|
| Private generateEmailVerificationToken |
generateEmailVerificationToken()
|
|
Generate a secure email verification token
Returns :
string
|
| Private generatePasswordSetupToken |
generatePasswordSetupToken()
|
|
Generate a secure password setup token
Returns :
string
|
| Private getFormattedParticipant | ||||||
getFormattedParticipant(participant: Record<string | unknown>)
|
||||||
|
Parameters :
Returns :
Record<string, unknown>
|
| Async requestPasswordReset | ||||||
requestPasswordReset(dto: RequestPasswordResetDto)
|
||||||
|
Request password reset - generates a password setup token and sends email
Parameters :
Returns :
Promise<literal type>
|
| Async resendEmailVerification | ||||||
resendEmailVerification(email: string)
|
||||||
|
Resend email verification token (lookup by email; for users who are not yet verified / cannot authenticate)
Parameters :
Returns :
Promise<literal type>
|
| Private Async sendEmailVerificationEmail | ||||||
sendEmailVerificationEmail(data: literal type)
|
||||||
|
Send email verification email
Parameters :
Returns :
Promise<void>
|
| Private Async sendPasswordResetEmail | ||||||
sendPasswordResetEmail(data: literal type)
|
||||||
|
Send password reset email to a participant
Parameters :
Returns :
Promise<void>
|
| Async setPasswordForLoggedInUser |
setPasswordForLoggedInUser(participantId: number, password: string)
|
|
Initial password setup for a logged-in participant who has no password yet (e.g. invited user). Does not replace admin reset or email token flows: if a password already exists, callers must use request-password-reset or an admin reset instead—this avoids session hijackers changing an existing password.
Returns :
Promise<literal type>
|
| Async setupPassword | ||||||
setupPassword(dto: SetupPasswordDto)
|
||||||
|
Setup password using setup token
Parameters :
Returns :
Promise<literal type>
|
| Async signIn | ||||||
signIn(dto: ParticipantSignInDto)
|
||||||
|
Parameters :
Returns :
Promise<literal type>
|
| Async signInWithToken | ||||||
signInWithToken(accessToken: string)
|
||||||
|
Parameters :
Returns :
Promise<literal type>
|
| Async signUp | ||||||
signUp(dto: ParticipantSignUpDto)
|
||||||
|
Parameters :
Returns :
Promise<literal type>
|
| Async validateSetupToken | ||||||
validateSetupToken(token: string)
|
||||||
|
Validate password setup token without setting password
Parameters :
Returns :
Promise<literal type>
|
| Async verifyEmail | ||||||
verifyEmail(token: string)
|
||||||
|
Verify email address using verification token
Parameters :
Returns :
Promise<literal type>
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(CLAuthService.name)
|
import { BNestEmailSenderService, requireEnv } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { JwtService } from "@nestjs/jwt";
import { Prisma } from "@prisma/client";
import * as argon from "argon2";
import * as crypto from "crypto";
import { PARTICIPANT_EVENTS, ParticipantActivityEvent } from "../participant/events/participant.events";
import { ParticipantActivityType } from "../participant/participant-activity.service";
import { ParticipantSignInDto, ParticipantSignUpDto, RequestPasswordResetDto } from "./dto";
import { SetupPasswordDto } from "./dto/password-setup.dto";
@Injectable()
export class CLAuthService {
private readonly logger = new Logger(CLAuthService.name);
constructor(
private prisma: BNestPrismaService,
private jwt: JwtService,
private config: ConfigService,
private eventEmitter: EventEmitter2,
private emailSender: BNestEmailSenderService,
) {}
async signIn(dto: ParticipantSignInDto): Promise<{
accessToken: string;
tokenType: "participant";
user: Record<string, unknown>;
emailVerified: boolean;
}> {
// Normalize email to lowercase for case-insensitive lookup
const normalizedEmail = dto.email.toLowerCase().trim();
// Find the participant by email (case-insensitive)
const participant = await this.prisma.client.participant.findUnique({
where: {
email: normalizedEmail,
},
include: {
company: {
select: {
id: true,
name: true,
is_active: true,
},
},
},
});
// If participant does not exist, throw exception
if (!participant) {
throw new UnauthorizedException("Invalid credentials");
}
// Check if participant is active
if (!participant.is_active) {
throw new ForbiddenException("Your account has been deactivated. Please contact support.");
}
// Check if the participant's company is active
if (!participant.company?.is_active) {
this.logger.warn(`[LOGIN BLOCKED] Participant ${participant.id} login blocked — Company "${participant.company?.name}" is inactive.`);
throw new ForbiddenException("Your account access is currently unavailable. Please contact your Administrator for assistance.");
}
// Check if participant has a password hash
if (!participant.password_hash) {
throw new UnauthorizedException(
"No password set for this account. Please request a password reset or contact support.",
);
}
// Compare password
const pwMatches = await argon.verify(participant.password_hash, dto.password);
// If password incorrect, throw exception
if (!pwMatches) {
throw new UnauthorizedException("Invalid credentials");
}
// Emit activity event (listener will update last_login)
this.eventEmitter.emit(
PARTICIPANT_EVENTS.ACTIVITY,
new ParticipantActivityEvent(participant.id, ParticipantActivityType.LOGIN, {
email: normalizedEmail,
company_id: participant.company_id,
}),
);
// Generate access token
const accessToken = await this.generateAccessToken(participant.id, participant.email);
// Format user data (remove sensitive information)
const user = this.getFormattedParticipant(participant);
return {
accessToken,
tokenType: "participant",
user,
emailVerified: participant.email_verified, // Include email verification status
};
}
async signUp(dto: ParticipantSignUpDto): Promise<{
success: boolean;
message: string;
}> {
try {
// Normalize email to lowercase for case-insensitive handling
const normalizedEmail = dto.email.toLowerCase().trim();
// Check if company exists and is active
const company = await this.prisma.client.company.findUnique({
where: { id: dto.company_id },
});
if (!company) {
throw new ForbiddenException("Invalid company");
}
if (!company.is_active) {
throw new ForbiddenException("Company is not active");
}
// Check if email already exists (case-insensitive)
const existingParticipant = await this.prisma.client.participant.findUnique({
where: { email: normalizedEmail },
});
if (existingParticipant) {
throw new ForbiddenException("Email already registered");
}
// Generate password hash
const password_hash = await argon.hash(dto.password);
// Create participant with normalized email
await this.prisma.client.participant.create({
data: {
company_id: dto.company_id,
first_name: dto.first_name,
last_name: dto.last_name,
email: normalizedEmail,
phone: dto.phone,
password_hash,
email_verified: dto.email_verified ?? false,
is_active: true,
},
});
return {
success: true,
message: "Account created successfully. Please sign in.",
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
throw new ForbiddenException("Email already registered");
}
}
throw error;
}
}
async signInWithToken(accessToken: string): Promise<{
accessToken: string;
tokenType: "participant";
user: Record<string, unknown>;
emailVerified?: boolean;
}> {
try {
const payload = this.jwt.verify(accessToken, {
secret: this.config.get("JWT_SECRET"),
}) as { email?: string; sub?: number };
if (!payload || !payload.email) {
throw new UnauthorizedException("Invalid token");
}
// Normalize email to lowercase for case-insensitive lookup
const email = payload.email.toLowerCase().trim();
const participant = await this.prisma.client.participant.findUnique({
where: { email },
include: {
company: {
select: {
id: true,
name: true,
is_active: true,
},
},
},
});
if (!participant) {
throw new UnauthorizedException("Participant not found");
}
if (!participant.is_active) {
throw new ForbiddenException("Your account has been deactivated");
}
// Check if the participant's company is active
if (!participant.company?.is_active) {
this.logger.warn(`[TOKEN REFRESH BLOCKED] Participant ${participant.id} token refresh blocked — Company "${participant.company?.name}" is inactive.`);
throw new ForbiddenException("Your account access is currently unavailable. Please contact your Administrator for assistance.");
}
// Generate new access token
const newAccessToken = await this.generateAccessToken(participant.id, participant.email);
// Format user data
const user = this.getFormattedParticipant(participant);
return {
accessToken: newAccessToken,
tokenType: "participant",
user,
emailVerified: participant.email_verified, // Include email verification status
};
} catch {
throw new UnauthorizedException("Invalid token");
}
}
private async generateAccessToken(participantId: number, email: string): Promise<string> {
const payload = {
sub: participantId,
email,
type: "participant", // Distinguish from admin users
};
const secret = this.config.get("JWT_SECRET");
const token = await this.jwt.signAsync(payload, {
expiresIn: "10h",
secret: secret,
});
return token;
}
/**
* Setup password using setup token
*/
async setupPassword(dto: SetupPasswordDto): Promise<{ success: boolean; message: string }> {
try {
// Find the token
const tokenRecord = await this.prisma.client.passwordSetupToken.findUnique({
where: { token: dto.token },
include: { participant: true },
});
if (!tokenRecord) {
throw new BadRequestException("Invalid or expired password setup token");
}
// Check if token is already used
if (tokenRecord.used_at) {
throw new BadRequestException("This password setup link has already been used");
}
// Check if token is expired
if (new Date() > tokenRecord.expires_at) {
throw new BadRequestException("Password setup link has expired. Please request a new one.");
}
// Check if participant is active
if (!tokenRecord.participant.is_active) {
throw new ForbiddenException("Your account has been deactivated");
}
// Hash the new password
const passwordHash = await argon.hash(dto.password);
// Update participant password, verify email (since they accessed the email link), and mark token as used in a transaction
await this.prisma.client.$transaction(async (tx) => {
// Update participant password and automatically verify email
// This is safe because they've proven access to their email by clicking the password setup link
await tx.participant.update({
where: { id: tokenRecord.participant_id },
data: {
password_hash: passwordHash,
email_verified: true, // Auto-verify email when password is set up via email link
email_verified_at: new Date(),
},
});
// Mark token as used
await tx.passwordSetupToken.update({
where: { id: tokenRecord.id },
data: { used_at: new Date() },
});
// If there's an email verification token, mark it as used as well (since email is now verified)
const emailVerificationToken = await tx.emailVerificationToken.findUnique({
where: { participant_id: tokenRecord.participant_id },
});
if (emailVerificationToken && !emailVerificationToken.used_at) {
await tx.emailVerificationToken.update({
where: { id: emailVerificationToken.id },
data: { used_at: new Date() },
});
}
});
return {
success: true,
message: "Password has been set successfully. You can now sign in.",
};
} catch (error) {
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
throw error;
}
throw new BadRequestException("Failed to setup password. Please try again.");
}
}
/**
* Validate password setup token without setting password
*/
async validateSetupToken(token: string): Promise<{ valid: boolean; message?: string }> {
try {
if (!token) {
return { valid: false, message: "Token is required" };
}
// Find the token
const tokenRecord = await this.prisma.client.passwordSetupToken.findUnique({
where: { token },
include: { participant: true },
});
if (!tokenRecord) {
return { valid: false, message: "Invalid or expired password setup token" };
}
// Check if token is already used
if (tokenRecord.used_at) {
return { valid: false, message: "This password setup link has already been used" };
}
// Check if token is expired
if (new Date() > tokenRecord.expires_at) {
return { valid: false, message: "Password setup link has expired" };
}
// Check if participant is active
if (!tokenRecord.participant.is_active) {
return { valid: false, message: "Your account has been deactivated" };
}
return { valid: true };
} catch {
return { valid: false, message: "Failed to validate token" };
}
}
/**
* Initial password setup for a logged-in participant who has no password yet (e.g. invited user).
* Does not replace admin reset or email token flows: if a password already exists, callers must use
* request-password-reset or an admin reset instead—this avoids session hijackers changing an existing password.
*/
async setPasswordForLoggedInUser(
participantId: number,
password: string,
): Promise<{ success: boolean; message: string }> {
// Find the participant
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
});
if (!participant) {
throw new NotFoundException("Participant not found");
}
if (participant.password_hash) {
throw new ForbiddenException(
"A password is already set for this account. Use Forgot password or contact your administrator to reset it.",
);
}
// Hash the new password
const passwordHash = await argon.hash(password);
// Update participant password
await this.prisma.client.participant.update({
where: { id: participantId },
data: { password_hash: passwordHash },
});
return {
success: true,
message: "Password has been set successfully",
};
}
/**
* Request password reset - generates a password setup token and sends email
*/
async requestPasswordReset(dto: RequestPasswordResetDto): Promise<{ success: boolean; message: string }> {
try {
// Normalize email to lowercase for case-insensitive lookup
const normalizedEmail = dto.email.toLowerCase().trim();
// Find the participant by email (case-insensitive)
const participant = await this.prisma.client.participant.findUnique({
where: { email: normalizedEmail },
include: {
company: {
select: {
id: true,
name: true,
},
},
},
});
// Validate that email exists in database
if (!participant) {
this.logger.warn(`Password reset requested for non-existent email: ${normalizedEmail}`);
throw new BadRequestException("No account found with this email address. Please check your email and try again.");
}
// Check if participant is active
if (!participant.is_active) {
this.logger.warn(`Password reset requested for inactive participant: ${normalizedEmail}`);
throw new BadRequestException("This account is inactive. Please contact support for assistance.");
}
// Check if password setup token already exists and is still valid
const existingToken = await this.prisma.client.passwordSetupToken.findUnique({
where: { participant_id: participant.id },
});
let setupToken: string;
if (existingToken && !existingToken.used_at && new Date() < existingToken.expires_at) {
// Use existing valid token
setupToken = existingToken.token;
this.logger.log(`Reusing existing password setup token for participant: ${participant.id}`);
} else {
// Generate new token (7 days expiry)
setupToken = this.generatePasswordSetupToken();
const tokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
// Create or update password setup token
await this.prisma.client.passwordSetupToken.upsert({
where: { participant_id: participant.id },
create: {
participant_id: participant.id,
token: setupToken,
expires_at: tokenExpiry,
},
update: {
token: setupToken,
expires_at: tokenExpiry,
used_at: null, // Reset if previously used
},
});
this.logger.log(`Generated new password setup token for participant: ${participant.id}`);
}
// Send password reset email
const frontendUrl = requireEnv("FRONTEND_URL");
const passwordSetupUrl = `${frontendUrl}/setup-password?token=${setupToken}`;
await this.sendPasswordResetEmail({
participantEmail: participant.email,
participantFirstName: participant.first_name,
participantLastName: participant.last_name,
companyName: participant.company.name,
passwordSetupUrl,
});
return {
success: true,
message: "Password reset link has been sent to your email address.",
};
} catch (error) {
this.logger.error(`Failed to process password reset request for ${dto.email}:`, error);
// Re-throw BadRequestException (validation errors) to show proper error message
if (error instanceof BadRequestException) {
throw error;
}
// For other errors, throw a generic error
throw new BadRequestException("Failed to send password reset link. Please try again later.");
}
}
/**
* Generate a secure password setup token
*/
private generatePasswordSetupToken(): string {
// Generate a secure random token (32 bytes = 64 hex characters)
return crypto.randomBytes(32).toString("hex");
}
/**
* Send password reset email to a participant
*/
private async sendPasswordResetEmail(data: {
participantEmail: string;
participantFirstName: string;
participantLastName: string;
companyName: string;
passwordSetupUrl: string;
}): Promise<void> {
try {
await this.emailSender.sendTemplatedEmail({
to: data.participantEmail,
templateKey: "account.password.reset",
variables: {
"user.name": data.participantFirstName,
"reset.url": data.passwordSetupUrl,
},
metadata: {
companyName: data.companyName,
triggeredBy: "password_reset_request",
},
});
this.logger.log(`Password reset email sent to: ${data.participantEmail}`);
} catch (emailError) {
this.logger.error("Failed to send password reset email:", emailError);
throw emailError;
}
}
/**
* Verify email address using verification token
*/
async verifyEmail(token: string): Promise<{ success: boolean; message: string }> {
try {
// Find the token
const tokenRecord = await this.prisma.client.emailVerificationToken.findUnique({
where: { token },
include: { participant: true },
});
if (!tokenRecord) {
throw new BadRequestException("Invalid or expired email verification token");
}
// Check if token is already used
if (tokenRecord.used_at) {
throw new BadRequestException("This email verification link has already been used");
}
// Check if token is expired
if (new Date() > tokenRecord.expires_at) {
throw new BadRequestException("Email verification link has expired. Please request a new one.");
}
// Check if participant is active
if (!tokenRecord.participant.is_active) {
throw new ForbiddenException("Your account has been deactivated");
}
// Update participant email verification status and mark token as used in a transaction
await this.prisma.client.$transaction(async (tx) => {
// Update participant email verification
await tx.participant.update({
where: { id: tokenRecord.participant_id },
data: {
email_verified: true,
email_verified_at: new Date(),
},
});
// Mark token as used
await tx.emailVerificationToken.update({
where: { id: tokenRecord.id },
data: { used_at: new Date() },
});
});
return {
success: true,
message: "Email has been verified successfully. You can now access your dashboard.",
};
} catch (error) {
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
throw error;
}
throw new BadRequestException("Failed to verify email. Please try again.");
}
}
/**
* Resend email verification token (lookup by email; for users who are not yet verified / cannot authenticate)
*/
async resendEmailVerification(email: string): Promise<{ success: boolean; message: string }> {
try {
const normalizedEmail = email.toLowerCase().trim();
const participant = await this.prisma.client.participant.findUnique({
where: { email: normalizedEmail },
include: { company: true },
});
if (!participant) {
this.logger.warn(`Resend verification requested for unknown email: ${normalizedEmail}`);
throw new BadRequestException("No account found with this email address. Please check your email and try again.");
}
if (!participant.is_active) {
this.logger.warn(`Resend verification requested for inactive participant: ${normalizedEmail}`);
throw new BadRequestException("This account is inactive. Please contact support for assistance.");
}
const participantId = participant.id;
if (participant.email_verified) {
return {
success: true,
message: "Email is already verified",
};
}
// Generate new email verification token
const token = this.generateEmailVerificationToken();
const tokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Create or update email verification token
await this.prisma.client.emailVerificationToken.upsert({
where: { participant_id: participantId },
create: {
participant_id: participantId,
token,
expires_at: tokenExpiry,
},
update: {
token,
expires_at: tokenExpiry,
used_at: null, // Reset if previously used
},
});
// Send verification email
const fromConfig = this.config.get<string>("FRONTEND_URL")?.trim() ?? "";
const frontendUrl = fromConfig !== "" ? fromConfig : requireEnv("FRONTEND_URL");
const verificationUrl = `${frontendUrl}/verify-email?token=${token}`;
await this.sendEmailVerificationEmail({
email: participant.email,
firstName: participant.first_name,
lastName: participant.last_name,
verificationUrl,
});
return {
success: true,
message: "Email verification link has been sent to your email address",
};
} catch (error) {
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException("Failed to resend verification email. Please try again.");
}
}
/**
* Generate a secure email verification token
*/
private generateEmailVerificationToken(): string {
const crypto = require("crypto");
return crypto.randomBytes(32).toString("hex");
}
/**
* Send email verification email
*/
private async sendEmailVerificationEmail(data: {
email: string;
firstName: string;
lastName: string;
verificationUrl: string;
}): Promise<void> {
try {
await this.emailSender.sendTemplatedEmail({
to: data.email,
templateKey: "account.email.verification",
variables: {
"user.name": `${data.firstName} ${data.lastName}`,
"verification.url": data.verificationUrl,
},
metadata: {
triggeredBy: "email_verification_resend",
},
});
this.logger.log(`Email verification email sent to: ${data.email}`);
} catch (emailError) {
this.logger.error("Failed to send email verification email:", emailError);
throw emailError;
}
}
private getFormattedParticipant(participant: Record<string, unknown>): Record<string, unknown> {
const formatted = { ...participant };
delete formatted["password_hash"]; // Remove sensitive data
return formatted;
}
}