Files
HOA_Financial_Platform/backend/src/modules/auth/auth.service.ts
olsch01 208c1dd7bc security: address assessment findings and bump to v2026.3.11
- C1: Disable Swagger UI in production (env gate)
- M1+M2: Add Helmet.js for security headers (CSP, X-Frame-Options,
  X-Content-Type-Options, Referrer-Policy) and remove X-Powered-By
- H2: Add @nestjs/throttler rate limiting (5 req/min on login/register)
- M4: Remove orgSchema from JWT payload and client-side storage;
  tenant middleware now resolves schema from orgId via cached DB lookup
- L1: Fix Chatwoot user identification (read from auth store on ready)
- Remove schemaName from frontend Organization type and UI displays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:32:51 -04:00

213 lines
6.4 KiB
TypeScript

import {
Injectable,
UnauthorizedException,
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private dataSource: DataSource,
) {}
async register(dto: RegisterDto) {
const existing = await this.usersService.findByEmail(dto.email);
if (existing) {
throw new ConflictException('Email already registered');
}
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = await this.usersService.create({
email: dto.email,
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
});
return this.generateTokenResponse(user);
}
async validateUser(email: string, password: string): Promise<User> {
const user = await this.usersService.findByEmail(email);
if (!user || !user.passwordHash) {
throw new UnauthorizedException('Invalid credentials');
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
async login(user: User, ipAddress?: string, userAgent?: string) {
await this.usersService.updateLastLogin(user.id);
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
const u = fullUser || user;
// Check if user's organizations are all suspended/archived
const orgs = u.userOrganizations || [];
if (orgs.length > 0 && !u.isSuperadmin) {
const activeOrgs = orgs.filter(
(uo) => uo.organization && !['suspended', 'archived'].includes(uo.organization.status),
);
if (activeOrgs.length === 0) {
throw new UnauthorizedException(
'Your organization has been suspended. Please contact your administrator.',
);
}
}
// Record login in history (org_id is null at initial login)
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
return this.generateTokenResponse(u);
}
async getProfile(userId: string) {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) {
throw new UnauthorizedException('User not found');
}
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
organizations: user.userOrganizations?.map((uo) => ({
id: uo.organization.id,
name: uo.organization.name,
role: uo.role,
})) || [],
};
}
async switchOrganization(userId: string, organizationId: string, ipAddress?: string, userAgent?: string) {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) {
throw new UnauthorizedException('User not found');
}
const membership = user.userOrganizations?.find(
(uo) => uo.organizationId === organizationId && uo.isActive,
);
if (!membership) {
throw new UnauthorizedException('Not a member of this organization');
}
// Block access to suspended/archived organizations
const orgStatus = membership.organization?.status;
if (orgStatus && ['suspended', 'archived'].includes(orgStatus)) {
throw new ForbiddenException(
`This organization has been ${orgStatus}. Please contact your administrator.`,
);
}
const payload = {
sub: user.id,
email: user.email,
orgId: membership.organizationId,
role: membership.role,
};
// Record org switch in login history
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
return {
accessToken: this.jwtService.sign(payload),
organization: {
id: membership.organization.id,
name: membership.organization.name,
role: membership.role,
settings: membership.organization.settings || {},
},
};
}
async markIntroSeen(userId: string): Promise<void> {
await this.usersService.markIntroSeen(userId);
}
private async recordLoginHistory(
userId: string,
organizationId: string | null,
ipAddress?: string,
userAgent?: string,
) {
try {
await this.dataSource.query(
`INSERT INTO shared.login_history (user_id, organization_id, ip_address, user_agent)
VALUES ($1, $2, $3, $4)`,
[userId, organizationId, ipAddress || null, userAgent || null],
);
} catch (err) {
// Non-critical — don't let login history failure block auth
}
}
private generateTokenResponse(user: User, impersonatedBy?: string) {
const allOrgs = user.userOrganizations || [];
// Filter out suspended/archived organizations
const orgs = allOrgs.filter(
(uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status),
);
const defaultOrg = orgs[0];
const payload: Record<string, any> = {
sub: user.id,
email: user.email,
isSuperadmin: user.isSuperadmin || false,
};
if (impersonatedBy) {
payload.impersonatedBy = impersonatedBy;
}
if (defaultOrg) {
payload.orgId = defaultOrg.organizationId;
payload.role = defaultOrg.role;
}
return {
accessToken: this.jwtService.sign(payload),
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false,
hasSeenIntro: user.hasSeenIntro || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,
name: uo.organization?.name,
status: uo.organization?.status,
role: uo.role,
})),
};
}
async impersonateUser(adminUserId: string, targetUserId: string) {
const targetUser = await this.usersService.findByIdWithOrgs(targetUserId);
if (!targetUser) {
throw new NotFoundException('User not found');
}
if (targetUser.isSuperadmin) {
throw new ForbiddenException('Cannot impersonate another superadmin');
}
return this.generateTokenResponse(targetUser, adminUserId);
}
}