- L2: Add server_tokens off to nginx configs to hide version - M1: Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy headers to all nginx routes - L3: Add global NoCacheInterceptor (Cache-Control: no-store) on all API responses to prevent caching of sensitive financial data - C1: Disable open registration by default (ALLOW_OPEN_REGISTRATION env) - H3: Add logout endpoint with correct HTTP 200 status code - M2: Implement full password reset flow (forgot-password, reset-password, change-password) with hashed tokens, 15-min expiry, single-use - Reduce JWT access token expiry from 24h to 1h - Add EmailService stub (logs to shared.email_log) - Add DB migration 016 for password_reset_tokens table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
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<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret';
|
|
this.appUrl = this.configService.get<string>('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<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(() => {});
|
|
|
|
// 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<any> {
|
|
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<string, any> = {
|
|
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<void> {
|
|
if (rawRefreshToken) {
|
|
await this.refreshTokenService.revokeToken(rawRefreshToken);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logout everywhere: revoke all refresh tokens for a user.
|
|
*/
|
|
async logoutEverywhere(userId: string): Promise<void> {
|
|
await this.refreshTokenService.revokeAllUserTokens(userId);
|
|
}
|
|
|
|
async markIntroSeen(userId: string): Promise<void> {
|
|
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<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;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|