All files / src/platform/auth auth.service.ts

0% Statements 0/61
0% Branches 0/37
0% Functions 0/6
0% Lines 0/59

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
import {
  Injectable,
  UnauthorizedException,
  ConflictException,
} from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { RegisterDto, LoginDto, RefreshTokenDto } from './dto/auth.dto';
import { Tenant } from '../tenants/schemas/tenant.schema';
import { User } from '../user/schemas/user.schema';
import { Role } from './schemas/role.schema';
import { RefreshToken } from './schemas/refresh-token.schema';
import { Subscription } from '../subscriptions/schemas/subscription.schema';
 
@Injectable()
export class AuthService {
  constructor(
    @InjectModel(Tenant.name) private readonly tenantModel: Model<Tenant>,
    @InjectModel(User.name) private readonly userModel: Model<User>,
    @InjectModel(Role.name) private readonly roleModel: Model<Role>,
    @InjectModel(RefreshToken.name)
    private readonly refreshTokenModel: Model<RefreshToken>,
    @InjectModel(Subscription.name)
    private readonly subscriptionModel: Model<Subscription>,
  ) {}
 
  /**
   * Register a new tenant with an admin user.
   * Creates tenant, default admin role, and the first user.
   */
  async register(dto: RegisterDto) {
    const existingUser = await this.userModel
      .findOne({ email: dto.email })
      .lean()
      .exec();
    if (existingUser) {
      throw new ConflictException('Email already registered');
    }
 
    const hashedPassword = await bcrypt.hash(dto.password, 12);
    const slugBase = dto.tenantName
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '');
    const slug = `${slugBase}-${crypto.randomBytes(3).toString('hex')}`;
 
    // Atlas supports replica set transactions natively
    const session = await this.tenantModel.db.startSession();
    session.startTransaction();
 
    try {
      // 1. Create Tenant
      const [tenant] = await this.tenantModel.create(
        [{ name: dto.tenantName, slug }],
        { session },
      );
 
      // 2. Create Default Admin Role
      const [adminRole] = await this.roleModel.create(
        [
          {
            name: 'Admin',
            description: 'Full access to all resources',
            isSystem: true,
            tenantId: (tenant as any)._id.toString(),
            permissions: ['*:*'],
          },
        ],
        { session },
      );
 
      // 3. Create User
      const [user] = await this.userModel.create(
        [
          {
            email: dto.email,
            password: hashedPassword,
            firstName: dto.firstName,
            lastName: dto.lastName,
            tenantId: (tenant as any)._id.toString(),
            isEmailVerified: false,
            roles: [adminRole.name],
          },
        ],
        { session },
      );
 
      // 4. Create default starter subscription for tenant
      await this.subscriptionModel.create(
        [
          {
            tenantId: (tenant as any)._id.toString(),
            status: 'trialing',
            planName: 'Starter',
            planVersionId: 'starter-v1',
            currentPeriodStart: new Date(),
            currentPeriodEnd: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days trial
          },
        ],
        { session },
      );
 
      await session.commitTransaction();
      session.endSession();
 
      const tokens = await this.generateTokens((user as any)._id.toString());
 
      return {
        user: {
          id: (user as any)._id.toString(),
          email: user.email,
          firstName: user.firstName,
          lastName: user.lastName,
        },
        tenant: {
          id: (tenant as any)._id.toString(),
          name: tenant.name,
          slug: tenant.slug,
        },
        ...tokens,
      };
    } catch (e) {
      await session.abortTransaction();
      session.endSession();
      throw e;
    }
  }
 
  /**
   * Login with email/password. Returns JWT + Refresh Token.
   */
  async login(dto: LoginDto) {
    const user = await this.userModel.findOne({ email: dto.email }).exec();
    if (!user || !user.isActive) {
      throw new UnauthorizedException('Invalid credentials');
    }
 
    const isPasswordValid = await bcrypt.compare(
      dto.password,
      user.password || '',
    );
    if (!isPasswordValid) {
      throw new UnauthorizedException('Invalid credentials');
    }
 
    // Update last login
    user.lastLoginAt = new Date();
    await user.save();
 
    const tenant = await this.tenantModel.findById(user.tenantId).lean().exec();
    const tokens = await this.generateTokens((user as any)._id.toString());
 
    return {
      user: {
        id: (user as any)._id.toString(),
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
        avatar: user.avatar,
        roles: user.roles,
      },
      tenant: tenant
        ? {
            id: (tenant as any)._id.toString(),
            name: tenant.name,
            slug: tenant.slug,
            logo: tenant.logo,
            primaryColor: tenant.primaryColor,
          }
        : null,
      ...tokens,
    };
  }
 
  /**
   * Refresh an expired access token using a valid refresh token.
   */
  async refreshToken(dto: RefreshTokenDto) {
    const storedToken = await this.refreshTokenModel
      .findOne({ token: dto.refreshToken })
      .exec();
 
    if (
      !storedToken ||
      storedToken.isRevoked ||
      storedToken.expiresAt < new Date()
    ) {
      throw new UnauthorizedException('Invalid or expired refresh token');
    }
 
    // Revoke old token (rotation)
    storedToken.isRevoked = true;
    await storedToken.save();
 
    return this.generateTokens(storedToken.userId);
  }
 
  /**
   * Logout by revoking all refresh tokens for a user.
   */
  async logout(userId: string) {
    await this.refreshTokenModel
      .updateMany({ userId, isRevoked: false }, { $set: { isRevoked: true } })
      .exec();
    return { message: 'Logged out successfully' };
  }
 
  // ============================================================
  // PRIVATE HELPERS
  // ============================================================
 
  private async generateTokens(userId: string) {
    // Standard mock token values (using cryptographically secure random bytes)
    const accessToken = crypto.randomBytes(32).toString('hex');
    const refreshTokenValue = crypto.randomBytes(48).toString('hex');
 
    await this.refreshTokenModel.create({
      token: refreshTokenValue,
      userId,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    });
 
    return {
      accessToken,
      refreshToken: refreshTokenValue,
      expiresIn: 900,
    };
  }
}