MaternAlert / src /auth /auth.service.ts
Auspicious14's picture
feat: add staff onboarding, password setup and welcome emails
131aa2a
Raw
History Blame Contribute Delete
13.5 kB
import {
Injectable,
UnauthorizedException,
ConflictException,
Logger,
NotFoundException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import * as bcrypt from "bcrypt";
import * as crypto from "crypto";
import { PrismaService } from "../database/prisma.service";
import { RegisterDto } from "./dto/register.dto";
import { LoginDto } from "./dto/login.dto";
import { AuthResponseDto } from "./dto/auth-response.dto";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
import { NotificationsService } from "../notifications/notifications.service";
import { EmailService } from "../email/email.service";
/**
* Authentication Service
*
* CLINICAL SAFETY PRINCIPLES:
* - Minimal PII storage
* - No health data in auth tables
* - No credential logging
* - Secure password hashing (bcrypt)
* - JWT-based stateless authentication
* - Refresh token rotation for security
*/
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly SALT_ROUNDS = 10;
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly notificationsService: NotificationsService,
private readonly emailService: EmailService,
) {}
/**
* Forgot password request
*/
async forgotPassword(forgotPasswordDto: ForgotPasswordDto): Promise<void> {
const { email, phone } = forgotPasswordDto;
if (!email && !phone) {
this.logger.warn("Forgot password attempt with no email or phone");
return;
}
// Find user — build conditions dynamically to avoid empty {} matching all rows
const conditions: any[] = [];
if (email)
conditions.push({
email: { equals: email.toLowerCase(), mode: "insensitive" },
});
if (phone) conditions.push({ phone });
const user = await this.prisma.userAuth.findFirst({
where: conditions.length === 1 ? conditions[0] : { OR: conditions },
});
if (!user) {
// Don't reveal if user exists for security
this.logger.warn(
`Forgot password attempt for non-existent user: ${email || phone}`,
);
return;
}
// Generate token (valid for 1 hour)
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date();
expires.setHours(expires.getHours() + 1);
// Save token
await this.prisma.userAuth.update({
where: { id: user.id },
data: {
passwordResetToken: token,
passwordResetExpiresAt: expires,
},
});
// Send reset message (via notification service)
await this.notificationsService.sendResetPasswordNotification(
user.id,
token,
);
this.logger.log(`Password reset token generated for user: ${user.id}`);
}
/**
* Reset password with token
*/
async resetPassword(resetPasswordDto: ResetPasswordDto): Promise<void> {
const { token, newPassword } = resetPasswordDto;
// Find user with valid token
const user = await this.prisma.userAuth.findFirst({
where: {
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
});
if (!user) {
throw new UnauthorizedException("Invalid or expired reset token");
}
// Hash new password
const passwordHash = await bcrypt.hash(newPassword, this.SALT_ROUNDS);
// Update user
await this.prisma.userAuth.update({
where: { id: user.id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetExpiresAt: null,
refreshToken: null, // Force logout from all devices
status: "ACTIVE",
},
});
this.logger.log(`Password reset successful for user: ${user.id}`);
}
/**
* Setup initial password for new users
*/
async setupPassword(token: string, password: string): Promise<void> {
// Find user with valid setup token
const user = await this.prisma.userAuth.findFirst({
where: {
passwordSetupToken: token,
passwordSetupExpiresAt: { gt: new Date() },
},
});
if (!user) {
throw new UnauthorizedException("Invalid or expired setup token");
}
// Hash new password
const passwordHash = await bcrypt.hash(password, this.SALT_ROUNDS);
// Update user
await this.prisma.userAuth.update({
where: { id: user.id },
data: {
passwordHash,
passwordSetupToken: null,
passwordSetupExpiresAt: null,
status: "ACTIVE",
},
});
this.logger.log(`Password setup successful for user: ${user.id}`);
}
/**
* Register a new user
*
* CONSTRAINTS:
* - Email OR phone required (not both)
* - Password must be at least 8 characters
* - No duplicate email/phone
*/
async register(registerDto: RegisterDto): Promise<AuthResponseDto> {
this.logger.log(
`Registration attempt for: ${registerDto.email || registerDto.phone}`,
);
const { email, phone, password } = registerDto;
// Validate that at least one identifier is provided
if (!email && !phone) {
throw new ConflictException("Either email or phone must be provided");
}
// Check for existing user — build conditions dynamically
const conditions: any[] = [];
const normalizedEmail = email?.toLowerCase();
if (normalizedEmail)
conditions.push({
email: { equals: normalizedEmail, mode: "insensitive" },
});
if (phone) conditions.push({ phone });
const existingUser = await this.prisma.userAuth.findFirst({
where: conditions.length === 1 ? conditions[0] : { OR: conditions },
});
if (existingUser) {
throw new ConflictException(
"User with this email or phone already exists",
);
}
// Hash password (NEVER log the password)
const passwordHash = await bcrypt.hash(password, this.SALT_ROUNDS);
// Create user
try {
const user = await this.prisma.userAuth.create({
data: {
email: normalizedEmail,
phone,
passwordHash,
status: "ACTIVE", // Patients register themselves, so set to active
},
});
this.logger.log(`New user registered: ${user.id}`);
// TRIGGER 2 — Send welcome email asynchronously
if (user.email) {
this.emailService
.sendWelcomeEmail(user.email, "Mama")
.catch((err) =>
this.logger.error(`Failed to send welcome email to ${user.email}`, err),
);
}
return this.generateTokens(user.id);
} catch (error: any) {
this.logger.error(
`Database error during registration: ${error.message}`,
error.stack,
);
throw error;
}
}
/**
* Login user
*
* CONSTRAINTS:
* - Email OR phone required
* - Password verification
* - No credential logging
*/
async login(loginDto: LoginDto): Promise<AuthResponseDto> {
const { email, phone, password } = loginDto;
// Validate that at least one identifier is provided
if (!email && !phone) {
throw new UnauthorizedException("Either email or phone must be provided");
}
// Find user — build conditions dynamically
const loginConditions: any[] = [];
if (email)
loginConditions.push({
email: { equals: email.toLowerCase(), mode: "insensitive" },
});
if (phone) loginConditions.push({ phone });
const user = await this.prisma.userAuth.findFirst({
where:
loginConditions.length === 1
? loginConditions[0]
: { OR: loginConditions },
});
if (!user) {
// Generic error to prevent user enumeration
throw new UnauthorizedException("Invalid credentials");
}
// Check user status
if (user.status === "PENDING") {
throw new UnauthorizedException(
"Your account has not been activated. Please check your email to set your password.",
);
}
if (user.status === "SUSPENDED") {
throw new UnauthorizedException("Your account has been suspended.");
}
// Check if password is set
if (!user.passwordHash) {
throw new UnauthorizedException("Please set your password first.");
}
// Verify password (NEVER log the password)
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException("Invalid credentials");
}
// Check if user is active
if (!user.isActive) {
throw new UnauthorizedException("Account is inactive");
}
this.logger.log(`User logged in: ${user.id}`);
// Generate tokens
return this.generateTokens(user.id);
}
/**
* Refresh access token
*/
async refreshToken(refreshToken: string): Promise<AuthResponseDto> {
try {
// Verify refresh token
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get("JWT_REFRESH_SECRET"),
});
// Find user
const user = await this.prisma.userAuth.findUnique({
where: { id: payload.sub },
});
if (!user || !user.isActive) {
throw new UnauthorizedException("Invalid refresh token");
}
// Verify stored refresh token matches
if (user.refreshToken !== refreshToken) {
throw new UnauthorizedException("Invalid refresh token");
}
// Generate new tokens
return this.generateTokens(user.id);
} catch (error) {
throw new UnauthorizedException("Invalid refresh token");
}
}
/**
* Logout user by invalidating stored refresh token
*
* SECURITY:
* - Clears refresh token from database so it can no longer be used
*/
async logout(userId: string): Promise<void> {
await this.prisma.userAuth.update({
where: { id: userId },
data: {
refreshToken: null,
pushToken: null, // Clear push token on logout for security
},
});
this.logger.log(`User logged out: ${userId}`);
}
/**
* Generate JWT access and refresh tokens
*
* SECURITY:
* - Access token: short-lived (1 hour)
* - Refresh token: longer-lived (7 days)
* - Refresh token stored in database for rotation
*/
private async generateTokens(userId: string): Promise<AuthResponseDto> {
// Get user data first
const userData = await this.prisma.userAuth.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
role: true,
healthcareWorker: {
select: {
id: true,
facilityId: true,
type: true,
isSuspended: true,
},
},
patient: {
select: {
id: true,
facilityId: true,
},
},
},
});
// Build JWT payload
const payload: any = {
sub: userId,
id: userData?.id,
email: userData?.email,
role: userData?.role,
};
if (userData?.healthcareWorker) {
payload.healthcareWorker = userData.healthcareWorker;
}
if (userData?.patient) {
payload.patient = userData.patient;
}
// Generate access token
const accessToken = this.jwtService.sign(payload, {
secret: this.configService.get("JWT_SECRET"),
expiresIn: this.configService.get("JWT_EXPIRATION"),
});
// Generate refresh token
const refreshToken = this.jwtService.sign({ sub: userId }, {
secret: this.configService.get("JWT_REFRESH_SECRET"),
expiresIn: this.configService.get("JWT_REFRESH_EXPIRATION"),
});
// Store refresh token in database and get full user
const user = await this.prisma.userAuth.update({
where: { id: userId },
data: { refreshToken },
select: {
id: true,
email: true,
phone: true,
isActive: true,
role: true,
healthcareWorker: {
select: {
id: true,
firstName: true,
lastName: true,
type: true,
facilityId: true,
isSuspended: true,
},
},
patient: {
select: {
id: true,
firstName: true,
lastName: true,
phone: true,
facilityId: true,
patientId: true,
},
},
},
});
return {
accessToken,
refreshToken,
userId,
user,
};
}
/**
* Validate user by ID (used by JWT strategy)
*/
async validateUser(userId: string) {
const user = await this.prisma.userAuth.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
phone: true,
isActive: true,
role: true,
healthcareWorker: {
select: {
id: true,
firstName: true,
lastName: true,
type: true,
facilityId: true,
isSuspended: true,
},
},
patient: {
select: {
id: true,
firstName: true,
lastName: true,
phone: true,
facilityId: true,
patientId: true,
},
},
},
});
if (!user || !user?.isActive) {
return null;
}
return user;
}
}