import { Injectable, UnauthorizedException, ConflictException, ForbiddenException, NotFoundException, BadRequestException, Logger, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; import * as bcrypt from 'bcryptjs'; import { randomBytes, createHash } from 'crypto'; import { UsersService } from '../users/users.service'; import { EmailService } from '../email/email.service'; import { RegisterDto } from './dto/register.dto'; import { User } from '../users/entities/user.entity'; import { RefreshTokenService } from './refresh-token.service'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); private readonly inviteSecret: string; private readonly appUrl: string; constructor( private usersService: UsersService, private jwtService: JwtService, private configService: ConfigService, private dataSource: DataSource, private refreshTokenService: RefreshTokenService, private emailService: EmailService, ) { this.inviteSecret = this.configService.get('INVITE_TOKEN_SECRET') || 'dev-invite-secret'; this.appUrl = this.configService.get('APP_URL') || 'http://localhost:5173'; } 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 { 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(() => {}); // If MFA is enabled, return a challenge token instead of full session if (u.mfaEnabled && u.mfaSecret) { const mfaToken = this.jwtService.sign( { sub: u.id, type: 'mfa_challenge' }, { expiresIn: '5m' }, ); return { mfaRequired: true, mfaToken }; } return this.generateTokenResponse(u); } /** * Complete login after MFA verification — generate full session tokens. */ async completeMfaLogin(userId: string): Promise { const user = await this.usersService.findByIdWithOrgs(userId); if (!user) throw new UnauthorizedException('User not found'); return this.generateTokenResponse(user); } 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, mfaEnabled: user.mfaEnabled || false, 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(() => {}); // Generate new refresh token for org switch const refreshToken = await this.refreshTokenService.createRefreshToken(user.id); return { accessToken: this.jwtService.sign(payload), refreshToken, organization: { id: membership.organization.id, name: membership.organization.name, role: membership.role, settings: membership.organization.settings || {}, }, }; } /** * Refresh an access token using a valid refresh token. */ async refreshAccessToken(rawRefreshToken: string) { const userId = await this.refreshTokenService.validateRefreshToken(rawRefreshToken); if (!userId) { throw new UnauthorizedException('Invalid or expired refresh token'); } const user = await this.usersService.findByIdWithOrgs(userId); if (!user) { throw new UnauthorizedException('User not found'); } // Generate a new access token (keep same org context if available) const orgs = (user.userOrganizations || []).filter( (uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status), ); const defaultOrg = orgs[0]; const payload: Record = { sub: user.id, email: user.email, isSuperadmin: user.isSuperadmin || false, }; if (defaultOrg) { payload.orgId = defaultOrg.organizationId; payload.role = defaultOrg.role; } return { accessToken: this.jwtService.sign(payload), }; } /** * Logout: revoke the refresh token. */ async logout(rawRefreshToken: string): Promise { if (rawRefreshToken) { await this.refreshTokenService.revokeToken(rawRefreshToken); } } /** * Logout everywhere: revoke all refresh tokens for a user. */ async logoutEverywhere(userId: string): Promise { await this.refreshTokenService.revokeAllUserTokens(userId); } async markIntroSeen(userId: string): Promise { await this.usersService.markIntroSeen(userId); } // ─── Invite Token (Activation) Methods ────────────────────────────── /** * Validate an invite/activation token. */ async validateInviteToken(token: string) { try { const payload = this.jwtService.verify(token, { secret: this.inviteSecret }); if (payload.type !== 'invite') throw new Error('Not an invite token'); const tokenHash = createHash('sha256').update(token).digest('hex'); const rows = await this.dataSource.query( `SELECT it.*, o.name as org_name FROM shared.invite_tokens it JOIN shared.organizations o ON o.id = it.organization_id WHERE it.token_hash = $1`, [tokenHash], ); if (rows.length === 0) throw new Error('Token not found'); const row = rows[0]; if (row.used_at) throw new BadRequestException('This activation link has already been used'); if (new Date(row.expires_at) < new Date()) throw new BadRequestException('This activation link has expired'); return { valid: true, email: payload.email, orgName: row.org_name, orgId: payload.orgId, userId: payload.userId }; } catch (err) { if (err instanceof BadRequestException) throw err; throw new BadRequestException('Invalid or expired activation link'); } } /** * Activate a user from an invite token (set password, activate, issue session). */ async activateUser(token: string, password: string, fullName: string) { const info = await this.validateInviteToken(token); const passwordHash = await bcrypt.hash(password, 12); const [firstName, ...rest] = fullName.trim().split(' '); const lastName = rest.join(' ') || ''; // Update user record await this.dataSource.query( `UPDATE shared.users SET password_hash = $1, first_name = $2, last_name = $3, is_email_verified = true, updated_at = NOW() WHERE id = $4`, [passwordHash, firstName, lastName, info.userId], ); // Mark invite token as used const tokenHash = createHash('sha256').update(token).digest('hex'); await this.dataSource.query( `UPDATE shared.invite_tokens SET used_at = NOW() WHERE token_hash = $1`, [tokenHash], ); // Issue session const user = await this.usersService.findByIdWithOrgs(info.userId); if (!user) throw new NotFoundException('User not found after activation'); return this.generateTokenResponse(user); } /** * Generate a signed invite token for a user/org pair. */ async generateInviteToken(userId: string, orgId: string, email: string): Promise { const token = this.jwtService.sign( { type: 'invite', userId, orgId, email }, { secret: this.inviteSecret, expiresIn: '72h' }, ); const tokenHash = createHash('sha256').update(token).digest('hex'); const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000); await this.dataSource.query( `INSERT INTO shared.invite_tokens (organization_id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)`, [orgId, userId, tokenHash, expiresAt], ); return token; } // ─── Password Reset Flow ────────────────────────────────────────── /** * Request a password reset. Generates a token, stores its hash, and sends an email. * Silently succeeds even if the email doesn't exist (prevents enumeration). */ async requestPasswordReset(email: string): Promise { const user = await this.usersService.findByEmail(email); if (!user) { // Silently return — don't reveal whether the account exists return; } // Invalidate any existing reset tokens for this user await this.dataSource.query( `UPDATE shared.password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL`, [user.id], ); // Generate a 64-byte random token const rawToken = randomBytes(64).toString('base64url'); const tokenHash = createHash('sha256').update(rawToken).digest('hex'); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes await this.dataSource.query( `INSERT INTO shared.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, [user.id, tokenHash, expiresAt], ); const resetUrl = `${this.appUrl}/reset-password?token=${rawToken}`; await this.emailService.sendPasswordResetEmail(user.email, resetUrl); } /** * Reset password using a valid reset token. */ async resetPassword(rawToken: string, newPassword: string): Promise { const tokenHash = createHash('sha256').update(rawToken).digest('hex'); const rows = await this.dataSource.query( `SELECT id, user_id, expires_at, used_at FROM shared.password_reset_tokens WHERE token_hash = $1`, [tokenHash], ); if (rows.length === 0) { throw new BadRequestException('Invalid or expired reset token'); } const record = rows[0]; if (record.used_at) { throw new BadRequestException('This reset link has already been used'); } if (new Date(record.expires_at) < new Date()) { throw new BadRequestException('This reset link has expired'); } // Update password const passwordHash = await bcrypt.hash(newPassword, 12); await this.dataSource.query( `UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, [passwordHash, record.user_id], ); // Mark token as used await this.dataSource.query( `UPDATE shared.password_reset_tokens SET used_at = NOW() WHERE id = $1`, [record.id], ); } /** * Change password for an authenticated user (requires current password). */ async changePassword(userId: string, currentPassword: string, newPassword: string): Promise { const user = await this.usersService.findById(userId); if (!user || !user.passwordHash) { throw new UnauthorizedException('User not found'); } 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.dataSource.query( `UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, [passwordHash, userId], ); } // ─── Private Helpers ────────────────────────────────────────────── 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 } } async 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 = { 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; } // Create refresh token const refreshToken = await this.refreshTokenService.createRefreshToken(user.id); return { accessToken: this.jwtService.sign(payload), refreshToken, 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, mfaEnabled: user.mfaEnabled || 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); } }