Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ yarn-error.log*

# Complied Typescript
dist
*.tsbuildinfo

# Runtime data
pids
Expand Down
7 changes: 7 additions & 0 deletions api/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,10 @@ S3_BUCKET=
S3_ACCESS_TOKEN=
# Secret key for the service account to the s3 bucket
S3_SECRET_TOKEN=

# Activity Log Config
# how many days until activity log entries expire
ACTIVITY_LOG_RETENTION_DAYS=30
# controls the repetition of the activity log cleanup cron job
ACTIVITY_LOG_CLEANUP_CRON_STRING="0 2 * * *"

14 changes: 14 additions & 0 deletions api/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor';
import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto';
import { ApiKeyGuard } from '../guards/api-key.guard';
import { UserDeleteDTO } from '../dtos/users/user-delete.dto';
import { UserAuditDto } from '../dtos/users/user-audit.dto';

@Controller('user')
@ApiTags('user')
Expand Down Expand Up @@ -285,6 +286,19 @@ export class UserController {
);
}

@Get(':id/audit')
@ApiOperation({
summary: 'Get user audit log',
operationId: 'getAuditLog',
})
@ApiOkResponse({ type: UserAuditDto })
@UseGuards(JwtAuthGuard, AdminOrJurisdictionalAdminGuard)
async getAuditLog(
@Param('id', new ParseUUIDPipe({ version: '4' })) userId: string,
): Promise<UserAuditDto> {
return await this.userService.getAuditLog(userId);
}

@Get(`:id`)
@ApiOperation({
summary: 'Get user by id',
Expand Down
76 changes: 76 additions & 0 deletions api/src/dtos/users/user-audit.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Expose, Type } from 'class-transformer';
import { IsArray, IsDate, IsString, ValidateNested } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ActivityLogAction } from '../../enums/shared/activity-log-action-enum';

export class AuditLogEntryDto {
@Expose()
@ApiProperty()
@Type(() => Date)
@IsDate()
createdAt: Date;

@Expose()
@ApiProperty({ enum: ActivityLogAction, enumName: 'ActivityLogAction' })
@IsString()
action: ActivityLogAction;

@Expose()
@ApiPropertyOptional()
metadata?: any;
}

export class AppSubmissionDto {
@Expose()
@ApiProperty()
@Type(() => Date)
@IsDate()
submissionDate: Date;

@Expose()
@ApiProperty()
@IsString()
listingName: string;

@Expose()
@ApiProperty()
@IsString()
confirmationCode: string;

@Expose()
@ApiPropertyOptional()
@IsString()
listingId?: string;

@Expose()
@ApiPropertyOptional()
@IsString()
applicationId?: string;

@Expose()
@ApiPropertyOptional()
ranking?: number;
}

export class UserAuditDto {
@Expose()
@ApiProperty({ type: AuditLogEntryDto, isArray: true })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AuditLogEntryDto)
loginAttempts: AuditLogEntryDto[];

@Expose()
@ApiProperty({ type: AppSubmissionDto, isArray: true })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AppSubmissionDto)
appSubmissions: AppSubmissionDto[];

@Expose()
@ApiProperty({ type: AuditLogEntryDto, isArray: true })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AuditLogEntryDto)
passwordChanges: AuditLogEntryDto[];
}
5 changes: 5 additions & 0 deletions api/src/enums/shared/activity-log-action-enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum ActivityLogAction {
login = 'login',
login_failed = 'login_failed',
password_update = 'password_update',
}
11 changes: 9 additions & 2 deletions api/src/modules/cron-job.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { Logger, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { SchedulerRegistry } from '@nestjs/schedule';
import { PrismaModule } from './prisma.module';
import { CronJobService } from '../services/cron-job.service';
import { ActivityLogCleanupService } from '../services/activity-log-cleanup.service';

@Module({
imports: [PrismaModule],
imports: [PrismaModule, ConfigModule],
controllers: [],
providers: [CronJobService, Logger, SchedulerRegistry],
providers: [
CronJobService,
ActivityLogCleanupService,
Logger,
SchedulerRegistry,
],
exports: [CronJobService],
})
export class CronJobModule {}
71 changes: 71 additions & 0 deletions api/src/passports/mfa.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { User } from '../dtos/users/user.dto';
import { PrismaService } from '../services/prisma.service';
import { mapTo } from '../utilities/mapTo';
import { ActivityLogAction } from '../enums/shared/activity-log-action-enum';
import {
isPasswordOutdated,
isPasswordValid,
Expand Down Expand Up @@ -53,6 +54,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') {
},
});
if (!rawUser) {
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login_failed,
metadata: {
email: dto.email,
reason: 'user_not_found',
},
},
});
throw new UnauthorizedException(
`user ${dto.email} attempted to log in, but does not exist`,
);
Expand All @@ -72,6 +83,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') {
)
) {
// if password TTL is expired
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login_failed,
userId: rawUser.id,
metadata: {
reason: 'password_outdated',
},
},
});
throw new UnauthorizedException(
`user ${rawUser.id} attempted to login, but password is no longer valid`,
);
Expand All @@ -81,13 +102,33 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') {
rawUser.failedLoginAttemptsCount + 1,
rawUser.id,
);
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login_failed,
userId: rawUser.id,
metadata: {
reason: 'invalid_password',
},
},
});
throw new UnauthorizedException({
failureCountRemaining:
Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS) -
rawUser.failedLoginAttemptsCount,
});
} else if (!rawUser.confirmedAt) {
// if user is not confirmed already
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login_failed,
userId: rawUser.id,
metadata: {
reason: 'not_confirmed',
},
},
});
throw new UnauthorizedException(
`user ${rawUser.id} attempted to login, but is not confirmed`,
);
Expand All @@ -96,6 +137,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') {
if (!rawUser.mfaEnabled) {
// if user is not an mfaEnabled user
await this.updateStoredUser(null, null, null, 0, rawUser.id);
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login,
userId: rawUser.id,
metadata: {
method: 'password',
},
},
});
return mapTo(User, rawUser);
}

Expand Down Expand Up @@ -139,6 +190,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') {
rawUser.failedLoginAttemptsCount,
rawUser.id,
);
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login_failed,
userId: rawUser.id,
metadata: {
reason: 'invalid_mfa_code',
},
},
});
throw new UnauthorizedException({
message: 'mfaUnauthorized',
failureCountRemaining:
Expand All @@ -162,6 +223,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') {
rawUser.failedLoginAttemptsCount,
rawUser.id,
);
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login,
userId: rawUser.id,
metadata: {
method: dto.mfaType || 'mfa',
},
},
});
return mapTo(User, rawUser);
}

Expand Down
41 changes: 41 additions & 0 deletions api/src/passports/single-use-code.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { mapTo } from '../utilities/mapTo';
import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options';
import { LoginViaSingleUseCode } from '../dtos/auth/login-single-use-code.dto';
import { OrderByEnum } from '../enums/shared/order-by-enum';
import { ActivityLogAction } from '../enums/shared/activity-log-action-enum';
import {
checkUserLockout,
singleUseCodePresent,
Expand Down Expand Up @@ -81,12 +82,32 @@ export class SingleUseCodeStrategy extends PassportStrategy(
},
});
if (!rawUser) {
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login_failed,
metadata: {
email: dto.email,
reason: 'user_not_found',
},
},
});
throw new UnauthorizedException(
`user ${dto.email} attempted to log in, but does not exist`,
);
}

if (!rawUser.agreedToTermsOfService && !dto.agreedToTermsOfService) {
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login_failed,
userId: rawUser.id,
metadata: {
reason: 'terms_of_service_not_accepted',
},
},
});
throw new BadRequestException(
`User ${rawUser.id} has not accepted the terms of service`,
);
Expand Down Expand Up @@ -138,6 +159,16 @@ export class SingleUseCodeStrategy extends PassportStrategy(
rawUser.failedLoginAttemptsCount,
rawUser.id,
);
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login_failed,
userId: rawUser.id,
metadata: {
reason: 'invalid_single_use_code',
},
},
});
throw new UnauthorizedException({
message: 'singleUseCodeUnauthorized',
failureCountRemaining:
Expand All @@ -156,6 +187,16 @@ export class SingleUseCodeStrategy extends PassportStrategy(
rawUser.failedLoginAttemptsCount,
rawUser.id,
);
await this.prisma.activityLog.create({
data: {
module: 'auth',
action: ActivityLogAction.login,
userId: rawUser.id,
metadata: {
method: 'single_use_code',
},
},
});
return mapTo(User, rawUser);
}

Expand Down
Loading