- 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>
213 lines
6.4 KiB
TypeScript
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);
|
|
}
|
|
}
|