File

src/auth/auth.service.ts

Description

Core authentication service.

Manages the full auth lifecycle including login (with optional 2FA), registration, token refresh, password management, and device token storage. All mutations are audit-logged and rate-limited via progressive account lockout.

Index

Methods

Constructor

constructor(prisma: PrismaService, jwtService: JwtService, configService: ConfigService, totpService: TotpService)
Parameters :
Name Type Optional
prisma PrismaService No
jwtService JwtService No
configService ConfigService No
totpService TotpService No

Methods

Async changePassword
changePassword(userId: string, currentPassword: string, newPassword: string)

Change the authenticated user's password.

Requires the current password for verification. On success, clears the mustChangePassword flag and returns fresh tokens so the current session remains valid.

Parameters :
Name Type Optional Description
userId string No
  • The authenticated user's ID.
currentPassword string No
  • The user's current password for verification.
newPassword string No
  • The new password (min 8 characters, must differ from current).
Returns : unknown

Success message with fresh access + refresh tokens.

Async disable2fa
disable2fa(userId: string, currentPassword: string)

Turn 2FA off. The user must re-confirm their password to prove possession of the account before we drop the second factor.

Parameters :
Name Type Optional
userId string No
currentPassword string No
Returns : unknown
Async getProfile
getProfile(userId: string)

Retrieve the authenticated user's full profile including organisation info.

Parameters :
Name Type Optional Description
userId string No
  • The authenticated user's ID (from JWT sub claim).
Returns : unknown

User profile with organisation details.

Async login
login(dto: LoginDto, ipAddress?: string)

Authenticate a user with email and password.

If the user has TOTP enabled, this returns a short-lived twoFactorToken instead of the full access/refresh pair. The client must present that token along with a valid 6-digit TOTP code to /auth/2fa/verify-login to complete the login flow.

Parameters :
Name Type Optional Description
dto LoginDto No
  • Login credentials (email, password).
ipAddress string Yes
  • Caller's IP for audit logging and lockout tracking.
Returns : unknown

Full auth response (user + tokens) or a 2FA challenge.

Async refresh
refresh(refreshToken: string)

Exchange a valid refresh token for a new access + refresh token pair.

The old refresh token is not revoked (stateless JWTs). Token rotation is achieved by the short access-token TTL (24h default) and longer refresh-token TTL (7d default).

Parameters :
Name Type Optional Description
refreshToken string No
  • The refresh JWT issued during login.
Returns : unknown

A fresh access + refresh token pair.

Async register
register(dto: RegisterDto)

Register is restricted to Admin/Super Admin roles and requires a valid invite code. All other users must be created by an admin via the User Management page.

Parameters :
Name Type Optional
dto RegisterDto No
Returns : unknown
Async requestPasswordReset
requestPasswordReset(email: string)

Initiate a password reset flow by generating a one-time reset token and emailing the user a reset link.

The token is SHA-256-hashed before storage so a database leak cannot be used to reset arbitrary accounts. The link expires in 1 hour. If SMTP is not configured, the reset URL is logged to stdout as a fallback for development environments.

Parameters :
Name Type Optional Description
email string No
  • The account email to reset.
Returns : Promise<literal type>

A generic success message (never reveals whether the email exists).

Async resetPassword
resetPassword(token: string, email: string, newPassword: string)

Complete a password reset by validating the one-time token and setting a new password.

Parameters :
Name Type Optional Description
token string No
  • The raw (unhashed) reset token from the email link.
email string No
  • The account email address.
newPassword string No
  • The new password to set (must meet policy requirements).
Returns : Promise<literal type>

Success confirmation message.

Async saveDeviceToken
saveDeviceToken(userId: string, token: string, platform?: string)

Register an FCM device token for push notifications.

Called by the mobile driver app after login to enable trip dispatch notifications, bay timeout alerts, and reallocation messages.

Parameters :
Name Type Optional Description
userId string No
  • The authenticated user's ID.
token string No
  • Firebase Cloud Messaging device registration token.
platform string Yes
  • Device platform identifier (defaults to 'android').
Returns : unknown

Confirmation message.

Async start2faEnrollment
start2faEnrollment(userId: string)

Begin 2FA enrollment for the current user. Returns the otpauth URI + a QR data URL the front-end can render. The secret is stored (encrypted) immediately but totpEnabled stays false until the user verifies a code via verify2faEnrollment.

Parameters :
Name Type Optional
userId string No
Returns : unknown
Async verify2faEnrollment
verify2faEnrollment(userId: string, code: string)

Confirm enrollment by checking the user's first TOTP code matches the secret we just stored. On success, flips totpEnabled = true.

Parameters :
Name Type Optional
userId string No
code string No
Returns : unknown
Async verify2faLogin
verify2faLogin(twoFactorToken: string, code: string, ipAddress?: string)

Second leg of 2FA login: validates the short-lived two-factor token

  • the current TOTP code. On success, returns the same shape as a normal login response (access + refresh).
Parameters :
Name Type Optional
twoFactorToken string No
code string No
ipAddress string Yes
Returns : unknown
import {
  Injectable,
  UnauthorizedException,
  ConflictException,
  BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { TotpService } from './totp.service';

/**
 * Core authentication service.
 *
 * Manages the full auth lifecycle including login (with optional 2FA),
 * registration, token refresh, password management, and device token
 * storage. All mutations are audit-logged and rate-limited via
 * progressive account lockout.
 *
 * @dependencies
 *   - {@link PrismaService} — tenant-aware database access
 *   - {@link JwtService} — JWT signing and verification
 *   - {@link ConfigService} — reads JWT secrets and expiry from env
 *   - {@link TotpService} — TOTP secret management and code validation
 */
@Injectable()
export class AuthService {
  constructor(
    private prisma: PrismaService,
    private jwtService: JwtService,
    private configService: ConfigService,
    private totpService: TotpService,
  ) {}

  /** Maximum consecutive failed login attempts before account lockout. */
  private readonly MAX_FAILED_ATTEMPTS = 10;

  /** Duration in minutes that a locked account remains inaccessible. */
  private readonly LOCKOUT_MINUTES = 15;

  /**
   * Authenticate a user with email and password.
   *
   * If the user has TOTP enabled, this returns a short-lived `twoFactorToken`
   * instead of the full access/refresh pair. The client must present that
   * token along with a valid 6-digit TOTP code to `/auth/2fa/verify-login`
   * to complete the login flow.
   *
   * @param dto - Login credentials (email, password).
   * @param ipAddress - Caller's IP for audit logging and lockout tracking.
   * @returns Full auth response (user + tokens) or a 2FA challenge.
   * @throws {UnauthorizedException} Invalid credentials, locked account, or deactivated user.
   */
  async login(dto: LoginDto, ipAddress?: string) {
    const user = await this.prisma.user.findUnique({
      where: { email: dto.email },
    });

    if (!user) {
      // Log failed attempt for non-existent user (don't reveal this to caller)
      this.logFailedAttempt(null, dto.email, ipAddress);
      throw new UnauthorizedException('Invalid credentials');
    }

    // Check if account is locked
    if (user.lockedUntil && new Date() < user.lockedUntil) {
      const remainingMin = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000);
      this.logFailedAttempt(user.id, dto.email, ipAddress);
      throw new UnauthorizedException(
        `Account is temporarily locked. Try again in ${remainingMin} minute${remainingMin > 1 ? 's' : ''}.`
      );
    }

    // Verify password
    const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
    if (!isPasswordValid) {
      await this.handleFailedLogin(user.id, dto.email, ipAddress);
      throw new UnauthorizedException('Invalid credentials');
    }

    if (!user.isActive) {
      throw new UnauthorizedException('Account is deactivated');
    }

    // ── 2FA gate ──────────────────────────────────────────────────────
    // If the user has TOTP enrolled, password alone is insufficient.
    // Issue a short-lived "twoFactorToken" the client must present along
    // with a valid 6-digit code to /auth/2fa/verify-login.
    if (user.totpEnabled) {
      const twoFactorToken = await this.jwtService.signAsync(
        { sub: user.id, scope: '2fa' },
        {
          secret: this.configService.get<string>('jwt.secret'),
          expiresIn: '5m',
        },
      );
      return {
        requires2FA: true,
        twoFactorToken,
        user: {
          id: user.id,
          email: user.email,
          firstName: user.firstName,
          lastName: user.lastName,
        },
      };
    }

    return this.completeLogin(user, ipAddress);
  }

  /**
   * Shared post-password-verified path. Called directly when 2FA is off,
   * or from {@link verify2faLogin} after the second factor is checked.
   */
  private async completeLogin(user: any, ipAddress?: string) {
    // Successful login — reset failed attempts
    await this.prisma.user.update({
      where: { id: user.id },
      data: {
        lastLoginAt: new Date(),
        failedLogins: 0,
        lockedUntil: null,
        lastFailedAt: null,
        lastFailedIp: null,
      },
    });

    // Log successful login
    await this.prisma.auditLog.create({
      data: {
        organizationId: user.organizationId,
        userId: user.id,
        action: 'auth.login',
        entityType: 'User',
        entityId: user.id,
        ipAddress: ipAddress || null,
      },
    }).catch(() => {}); // Non-blocking

    const tokens = await this.generateTokens(user.id, user.email, user.role, user.organizationId);

    return {
      user: {
        id: user.id,
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
        role: user.role,
        organizationId: user.organizationId,
        mustChangePassword: user.mustChangePassword || false,
        totpEnabled: user.totpEnabled || false,
      },
      ...tokens,
    };
  }

  /**
   * Second leg of 2FA login: validates the short-lived two-factor token
   * + the current TOTP code. On success, returns the same shape as a
   * normal login response (access + refresh).
   */
  async verify2faLogin(twoFactorToken: string, code: string, ipAddress?: string) {
    let payload: { sub: string; scope: string };
    try {
      payload = await this.jwtService.verifyAsync(twoFactorToken, {
        secret: this.configService.get<string>('jwt.secret'),
      });
    } catch {
      throw new UnauthorizedException('2FA challenge expired — please sign in again');
    }
    if (payload.scope !== '2fa') {
      throw new UnauthorizedException('Invalid 2FA token');
    }

    const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
    if (!user || !user.isActive) {
      throw new UnauthorizedException('Account no longer available');
    }
    if (!user.totpEnabled || !user.totpSecret) {
      throw new UnauthorizedException('2FA is not enabled for this account');
    }

    let secret: string;
    try {
      secret = this.totpService.decryptSecret(user.totpSecret);
    } catch {
      throw new UnauthorizedException('2FA configuration is invalid — contact your administrator');
    }

    if (!this.totpService.verifyCode(secret, code)) {
      // Treat a wrong TOTP like a wrong password — increments failed
      // counter and locks the account after MAX_FAILED_ATTEMPTS to deter
      // brute-forcing the 6-digit code (1M space).
      await this.handleFailedLogin(user.id, user.email, ipAddress);
      throw new UnauthorizedException('Invalid 2FA code');
    }

    return this.completeLogin(user, ipAddress);
  }

  // ── 2FA enrollment / disable ──────────────────────────────────────────

  /**
   * Begin 2FA enrollment for the current user. Returns the otpauth URI +
   * a QR data URL the front-end can render. The secret is stored
   * (encrypted) immediately but `totpEnabled` stays false until the user
   * verifies a code via {@link verify2faEnrollment}.
   */
  async start2faEnrollment(userId: string) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user) throw new UnauthorizedException('User not found');
    if (user.totpEnabled) {
      throw new BadRequestException('2FA is already enabled for this account');
    }

    const { secret, otpauthUrl, qrDataUrl } =
      await this.totpService.generateEnrollment(user.email);

    await this.prisma.user.update({
      where: { id: userId },
      data: { totpSecret: this.totpService.encryptSecret(secret) },
    });

    return { otpauthUrl, qrDataUrl };
  }

  /**
   * Confirm enrollment by checking the user's first TOTP code matches
   * the secret we just stored. On success, flips `totpEnabled = true`.
   */
  async verify2faEnrollment(userId: string, code: string) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user || !user.totpSecret) {
      throw new BadRequestException('Start enrollment before verifying');
    }
    if (user.totpEnabled) {
      throw new BadRequestException('2FA is already enabled');
    }

    const secret = this.totpService.decryptSecret(user.totpSecret);
    if (!this.totpService.verifyCode(secret, code)) {
      throw new BadRequestException('Invalid code — check your authenticator app and try again');
    }

    await this.prisma.user.update({
      where: { id: userId },
      data: { totpEnabled: true, totpEnrolledAt: new Date() },
    });

    await this.prisma.auditLog
      .create({
        data: {
          organizationId: user.organizationId,
          userId: user.id,
          action: 'auth.2fa_enabled',
          entityType: 'User',
          entityId: user.id,
        },
      })
      .catch(() => {});

    return { success: true, message: '2FA is now enabled on this account' };
  }

  /**
   * Turn 2FA off. The user must re-confirm their password to prove
   * possession of the account before we drop the second factor.
   */
  async disable2fa(userId: string, currentPassword: string) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user) throw new UnauthorizedException('User not found');
    if (!user.totpEnabled) {
      throw new BadRequestException('2FA is not currently enabled');
    }

    const ok = await bcrypt.compare(currentPassword, user.passwordHash);
    if (!ok) throw new UnauthorizedException('Current password is incorrect');

    await this.prisma.user.update({
      where: { id: userId },
      data: { totpEnabled: false, totpSecret: null, totpEnrolledAt: null },
    });

    await this.prisma.auditLog
      .create({
        data: {
          organizationId: user.organizationId,
          userId: user.id,
          action: 'auth.2fa_disabled',
          entityType: 'User',
          entityId: user.id,
        },
      })
      .catch(() => {});

    return { success: true, message: '2FA disabled' };
  }

  /**
   * Increment the failed login counter and lock the account if the threshold
   * is exceeded. Also logs the failed attempt to the audit trail.
   *
   * @param userId - The user's database ID.
   * @param email - Email address (for audit log).
   * @param ipAddress - Caller's IP address.
   */
  private async handleFailedLogin(userId: string, email: string, ipAddress?: string) {
    const user = await this.prisma.user.update({
      where: { id: userId },
      data: {
        failedLogins: { increment: 1 },
        lastFailedAt: new Date(),
        lastFailedIp: ipAddress || null,
      },
    });

    // Lock account after MAX_FAILED_ATTEMPTS
    if (user.failedLogins >= this.MAX_FAILED_ATTEMPTS) {
      await this.prisma.user.update({
        where: { id: userId },
        data: {
          lockedUntil: new Date(Date.now() + this.LOCKOUT_MINUTES * 60 * 1000),
        },
      });
    }

    // Log the failed attempt
    this.logFailedAttempt(userId, email, ipAddress);
  }

  /**
   * Write a failed-login audit log entry. Non-blocking (catches its own errors).
   *
   * @param userId - User ID if known, or null for non-existent accounts.
   * @param email - The email address that was attempted.
   * @param ipAddress - Caller's IP address.
   */
  private async logFailedAttempt(userId: string | null, email: string, ipAddress?: string) {
    // Find org for audit log
    const org = userId
      ? await this.prisma.user.findUnique({ where: { id: userId }, select: { organizationId: true } })
      : await this.prisma.organization.findFirst({ select: { id: true } });

    if (org) {
      await this.prisma.auditLog.create({
        data: {
          organizationId: 'organizationId' in org ? org.organizationId : org.id,
          userId: userId,
          action: 'auth.login_failed',
          entityType: 'User',
          entityId: email,
          ipAddress: ipAddress || null,
          newValues: { email, timestamp: new Date().toISOString() } as any,
        },
      }).catch(() => {}); // Non-blocking
    }
  }

  /**
   * Register is restricted to Admin/Super Admin roles and requires a valid invite code.
   * All other users must be created by an admin via the User Management page.
   */
  async register(dto: RegisterDto) {
    // Invite code is REQUIRED
    if (!dto.inviteCode) {
      throw new BadRequestException('An invite code is required to create an account. Contact your administrator.');
    }

    // Validate invite code
    const invite = await this.prisma.inviteCode.findUnique({
      where: { code: dto.inviteCode },
    });

    if (!invite) {
      throw new BadRequestException('Invalid invite code.');
    }
    if (invite.isUsed) {
      throw new BadRequestException('This invite code has already been used.');
    }
    if (invite.expiresAt && invite.expiresAt < new Date()) {
      throw new BadRequestException('This invite code has expired.');
    }

    // Only ADMIN and SUPER_ADMIN can self-register
    const allowedRoles = ['ADMIN', 'SUPER_ADMIN'];
    if (!allowedRoles.includes(invite.role)) {
      throw new BadRequestException('This invite code is not valid for self-registration.');
    }

    const existingUser = await this.prisma.user.findUnique({
      where: { email: dto.email },
    });

    if (existingUser) {
      throw new ConflictException('Email already registered');
    }

    const passwordHash = await bcrypt.hash(dto.password, 12);

    const user = await this.prisma.user.create({
      data: {
        email: dto.email,
        passwordHash,
        firstName: dto.firstName,
        lastName: dto.lastName,
        role: invite.role,
        organizationId: invite.organizationId,
        phone: dto.phone,
      },
      select: {
        id: true,
        email: true,
        firstName: true,
        lastName: true,
        role: true,
        organizationId: true,
      },
    });

    // Mark invite code as used
    await this.prisma.inviteCode.update({
      where: { id: invite.id },
      data: {
        isUsed: true,
        usedByEmail: dto.email,
        usedAt: new Date(),
      },
    });

    const tokens = await this.generateTokens(user.id, user.email, user.role, user.organizationId);

    return { user, ...tokens };
  }

  /**
   * Exchange a valid refresh token for a new access + refresh token pair.
   *
   * The old refresh token is not revoked (stateless JWTs). Token rotation
   * is achieved by the short access-token TTL (24h default) and longer
   * refresh-token TTL (7d default).
   *
   * @param refreshToken - The refresh JWT issued during login.
   * @returns A fresh access + refresh token pair.
   * @throws {UnauthorizedException} Token is expired, malformed, or belongs to a deactivated user.
   */
  async refresh(refreshToken: string) {
    try {
      const refreshSecret = this.configService.get<string>('jwt.refreshSecret');
      if (!refreshSecret) throw new Error('JWT_REFRESH_SECRET environment variable is required');
      const payload = this.jwtService.verify(refreshToken, {
        secret: refreshSecret,
      });

      const user = await this.prisma.user.findUnique({
        where: { id: payload.sub },
      });

      if (!user || !user.isActive) {
        throw new UnauthorizedException('Invalid refresh token');
      }

      return this.generateTokens(user.id, user.email, user.role, user.organizationId);
    } catch {
      throw new UnauthorizedException('Invalid or expired refresh token');
    }
  }

  /**
   * Retrieve the authenticated user's full profile including organisation info.
   *
   * @param userId - The authenticated user's ID (from JWT `sub` claim).
   * @returns User profile with organisation details.
   * @throws {UnauthorizedException} User not found (e.g., deleted after token issued).
   */
  async getProfile(userId: string) {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      select: {
        id: true,
        email: true,
        firstName: true,
        lastName: true,
        role: true,
        phone: true,
        avatarUrl: true,
        organizationId: true,
        isActive: true,
        lastLoginAt: true,
        mustChangePassword: true,
        totpEnabled: true,
        totpEnrolledAt: true,
        preferences: true,
        createdAt: true,
        organization: {
          select: { id: true, name: true, slug: true, logoUrl: true },
        },
      },
    });

    if (!user) {
      throw new UnauthorizedException('User not found');
    }

    return user;
  }

  /**
   * Register an FCM device token for push notifications.
   *
   * Called by the mobile driver app after login to enable trip dispatch
   * notifications, bay timeout alerts, and reallocation messages.
   *
   * @param userId - The authenticated user's ID.
   * @param token - Firebase Cloud Messaging device registration token.
   * @param platform - Device platform identifier (defaults to `'android'`).
   * @returns Confirmation message.
   */
  async saveDeviceToken(userId: string, token: string, platform?: string) {
    await this.prisma.user.update({
      where: { id: userId },
      data: {
        fcmToken: token,
        fcmPlatform: platform || 'android',
      },
    });
    return { message: 'Device token registered' };
  }

  /**
   * Initiate a password reset flow by generating a one-time reset token
   * and emailing the user a reset link.
   *
   * The token is SHA-256-hashed before storage so a database leak cannot
   * be used to reset arbitrary accounts. The link expires in 1 hour.
   * If SMTP is not configured, the reset URL is logged to stdout as a
   * fallback for development environments.
   *
   * @param email - The account email to reset.
   * @returns A generic success message (never reveals whether the email exists).
   */
  async requestPasswordReset(email: string): Promise<{ message: string }> {
    const user = await this.prisma.user.findUnique({ where: { email } });

    // Always return success (don't reveal if email exists)
    if (!user) {
      return { message: 'If an account with that email exists, a reset link has been sent.' };
    }

    const crypto = require('crypto');
    const resetToken = crypto.randomBytes(32).toString('hex');
    const resetTokenHash = crypto.createHash('sha256').update(resetToken).digest('hex');

    // Token expires in 1 hour
    const resetTokenExp = new Date(Date.now() + 60 * 60 * 1000);

    await this.prisma.user.update({
      where: { id: user.id },
      data: { resetToken: resetTokenHash, resetTokenExp },
    });

    const resetUrl = `${process.env.WEB_URL || 'http://localhost:3000'}/reset-password?token=${resetToken}&email=${encodeURIComponent(email)}`;

    try {
      const nodemailer = require('nodemailer');
      if (process.env.SMTP_HOST) {
        const transport = nodemailer.createTransport({
          host: process.env.SMTP_HOST,
          port: parseInt(process.env.SMTP_PORT || '587'),
          auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
        });
        await transport.sendMail({
          from: process.env.SMTP_FROM || 'FleetCommand <noreply@fleetcommand.io>',
          to: email,
          subject: 'FleetCommand — Password Reset',
          html: `
            <div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
              <div style="background:linear-gradient(135deg,#6366f1,#3b82f6);padding:20px 30px;border-radius:12px 12px 0 0">
                <h2 style="color:white;margin:0">Password Reset</h2>
              </div>
              <div style="background:#f8fafc;padding:30px;border:1px solid #e2e8f0;border-radius:0 0 12px 12px">
                <p>Hi ${user.firstName},</p>
                <p>You requested a password reset. Click the button below to set a new password:</p>
                <a href="${resetUrl}" style="display:inline-block;background:#6366f1;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;margin:16px 0">Reset Password</a>
                <p style="color:#64748b;font-size:14px">This link expires in 1 hour. If you didn't request this, ignore this email.</p>
                <p style="color:#94a3b8;font-size:12px;margin-top:20px">FleetCommand TMS</p>
              </div>
            </div>
          `,
        });
      } else {
        console.log(`[PASSWORD RESET] ${email}: ${resetUrl}`);
      }
    } catch (err) {
      console.error('Failed to send reset email:', err);
      console.log(`[PASSWORD RESET FALLBACK] ${email}: ${resetUrl}`);
    }

    return { message: 'If an account with that email exists, a reset link has been sent.' };
  }

  /**
   * Complete a password reset by validating the one-time token and setting
   * a new password.
   *
   * @param token - The raw (unhashed) reset token from the email link.
   * @param email - The account email address.
   * @param newPassword - The new password to set (must meet policy requirements).
   * @returns Success confirmation message.
   * @throws {UnauthorizedException} Token is invalid, expired, or does not match the email.
   */
  async resetPassword(token: string, email: string, newPassword: string): Promise<{ message: string }> {
    const crypto = require('crypto');
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

    const user = await this.prisma.user.findFirst({
      where: {
        email,
        resetToken: tokenHash,
        resetTokenExp: { gt: new Date() },
      },
    });

    if (!user) {
      throw new UnauthorizedException('Invalid or expired reset token');
    }

    const passwordHash = await bcrypt.hash(newPassword, 12);

    await this.prisma.user.update({
      where: { id: user.id },
      data: {
        passwordHash,
        resetToken: null,
        resetTokenExp: null,
      },
    });

    return { message: 'Password reset successfully. You can now log in with your new password.' };
  }

  /**
   * Change the authenticated user's password.
   *
   * Requires the current password for verification. On success, clears the
   * `mustChangePassword` flag and returns fresh tokens so the current
   * session remains valid.
   *
   * @param userId - The authenticated user's ID.
   * @param currentPassword - The user's current password for verification.
   * @param newPassword - The new password (min 8 characters, must differ from current).
   * @returns Success message with fresh access + refresh tokens.
   * @throws {UnauthorizedException} Current password is incorrect.
   * @throws {BadRequestException} New password fails policy (too short or same as current).
   */
  async changePassword(userId: string, currentPassword: string, newPassword: string) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user) throw new UnauthorizedException('User not found');

    if (newPassword.length < 8) {
      throw new BadRequestException('New password must be at least 8 characters');
    }
    if (newPassword === currentPassword) {
      throw new BadRequestException('New password must be different from the current one');
    }

    const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
    if (!isValid) throw new UnauthorizedException('Current password is incorrect');

    const passwordHash = await bcrypt.hash(newPassword, 12);
    await this.prisma.user.update({
      where: { id: userId },
      data: {
        passwordHash,
        // Always clear the must-change flag after a successful self-change.
        mustChangePassword: false,
      },
    });

    // Generate new tokens so the current session stays valid
    const freshTokens = await this.generateTokens(
      user.id, user.email, user.role, user.organizationId,
    );

    return { message: 'Password changed successfully.', ...freshTokens };
  }

  /**
   * Generate a JWT access + refresh token pair.
   *
   * The access token carries the user's `sub`, `email`, `role`, and
   * `organizationId` claims and is used by the `JwtStrategy` guard on
   * every protected route. The refresh token has a longer TTL and uses
   * a separate signing secret so that a leaked access token cannot be
   * used to mint new tokens.
   *
   * @param userId - User's database ID (becomes `sub` claim).
   * @param email - User's email address.
   * @param role - User's role (e.g., `ADMIN`, `DISPATCHER`, `DRIVER`).
   * @param organizationId - Tenant organisation ID.
   * @returns Object with `accessToken` and `refreshToken` strings.
   * @throws {Error} If `JWT_SECRET` or `JWT_REFRESH_SECRET` env vars are missing.
   */
  private async generateTokens(
    userId: string,
    email: string,
    role: string,
    organizationId: string,
  ) {
    const payload = { sub: userId, email, role, organizationId };

    const secret = this.configService.get<string>('jwt.secret');
    if (!secret) throw new Error('JWT_SECRET environment variable is required');
    const refreshSecret = this.configService.get<string>('jwt.refreshSecret');
    if (!refreshSecret) throw new Error('JWT_REFRESH_SECRET environment variable is required');

    const [accessToken, refreshToken] = await Promise.all([
      this.jwtService.signAsync(payload, {
        secret,
        expiresIn:
          this.configService.get<string>('jwt.expiresIn') || '24h',
      }),
      this.jwtService.signAsync(payload, {
        secret: refreshSecret,
        expiresIn:
          this.configService.get<string>('jwt.refreshExpiresIn') || '7d',
      }),
    ]);

    return { accessToken, refreshToken };
  }
}

results matching ""

    No results matching ""