diff --git a/.env.example b/.env.example index 8ce5f35..1ebae37 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,8 @@ JWT_EXPIRY_TIMEFRAME=3600 ADMIN_SECRET_KEY=sometext +REFRESH_TOKEN_EXPIRY=7 + GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_ID= diff --git a/.gitignore b/.gitignore index 43b85e7..82582e8 100644 --- a/.gitignore +++ b/.gitignore @@ -430,6 +430,3 @@ package-lock.json docker-compose.yml data/ .dev.env - -temp_auto_push.bat -temp_interactive_push.bat diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 03ec2de..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,2 +0,0 @@ -npx --yes lint-staged -npx jest diff --git a/src/database/migrations/1778335054510-migration.ts b/src/database/migrations/1778335054510-migration.ts new file mode 100644 index 0000000..596beb8 --- /dev/null +++ b/src/database/migrations/1778335054510-migration.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Migration1778335054510 implements MigrationInterface { + name = 'Migration1778335054510'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "terms_accepted" boolean NOT NULL DEFAULT false` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "terms_accepted"`); + } +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 37a9615..1b934b1 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Req } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import * as SYS_MSG from '@shared/constants/SystemMessages'; import { skipAuth } from '@shared/helpers/skipAuth'; @@ -9,7 +9,7 @@ import { LoginDto } from './dto/login.dto'; import { ChangePasswordDto } from './dto/change-password.dto'; import { SendOtpDto } from './dto/send-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; -import { SendOtpDocs, VerifyOtpDocs, ResendOtpDocs, LoginDocs, ChangePasswordDocs } from './docs/auth-swagger.doc'; +import { SendOtpDocs, VerifyOtpDocs, ResendOtpDocs, LoginDocs, ChangePasswordDocs, RegisterDocs } from './docs/auth-swagger.doc'; @ApiTags('Authentication') @Controller('auth') @@ -17,12 +17,8 @@ export default class RegistrationController { constructor(private readonly authService: AuthenticationService) { } @skipAuth() + @RegisterDocs() @Post('register') - @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: 'Register a new user' }) - @ApiBody({ type: CreateUserDTO }) - @ApiResponse({ status: HttpStatus.CREATED, description: SYS_MSG.USER_CREATED_SUCCESSFULLY }) - @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: SYS_MSG.USER_ACCOUNT_EXIST }) async register(@Body() body: CreateUserDTO) { return this.authService.createNewUser(body); } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 5919ceb..46a0197 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,7 +1,7 @@ -import { HttpStatus, Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import * as bcrypt from 'bcrypt'; import { randomInt } from 'crypto'; import authConfig from '@config/auth.config'; @@ -10,7 +10,9 @@ import { CustomHttpException } from '@shared/helpers/custom-http-filter'; import { User } from '@modules/user/entities/user.entity'; import { CreateUserDTO } from './dto/create-user.dto'; import { LoginDto } from './dto/login.dto'; +import { UserSession } from './entities/user-session.entity'; import { RedisService } from '@modules/redis/services/redis.service'; +import { AuthMetadata } from './entities/auth-metadata.entity'; import QueueService from '@modules/email/queue.service'; import { LockoutService } from './lockout.service'; import { SessionService } from './session.service'; @@ -22,6 +24,8 @@ const MAX_OTP_ATTEMPTS = 5; @Injectable() export default class AuthenticationService { + private readonly logger = new Logger(AuthenticationService.name); + constructor( @InjectRepository(User) private readonly userRepository: Repository, @@ -29,7 +33,15 @@ export default class AuthenticationService { private readonly redisService: RedisService, private readonly queueService: QueueService, private readonly lockoutService: LockoutService, - private readonly sessionService: SessionService + private readonly sessionService: SessionService, + + @InjectRepository(UserSession) + private readonly userSessionRepository: Repository, + + @InjectRepository(AuthMetadata) + private readonly authMetaData: Repository, + + private readonly dataSource: DataSource, ) { } async createNewUser(createUserDto: CreateUserDTO) { @@ -39,25 +51,65 @@ export default class AuthenticationService { } const hashedPassword = await bcrypt.hash(createUserDto.password, 10); - const saved = await this.userRepository.save( - this.userRepository.create({ + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + let saved: User; + + try { + const user = queryRunner.manager.create(User, { email: createUserDto.email, full_name: createUserDto.full_name, country: createUserDto.country ?? null, password: hashedPassword, auth_provider: 'email', - }) - ); + terms_accepted: createUserDto.terms_accepted, + }); + + saved = await queryRunner.manager.save(user); + + const authMetaData = queryRunner.manager.create(AuthMetadata, { + user_id: saved.id, + last_login_at: null, + }); + await queryRunner.manager.save(authMetaData); + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + + const err = error as Error; + this.logger.error(`Registration failed: ${err.message}`, err.stack); - await this.issueOtp(saved.email); + let errorMessage = SYS_MSG.SESSION_CREATION_FAILED; + const statusCode = HttpStatus.INTERNAL_SERVER_ERROR; - const access_token = this.jwtService.sign({ id: saved.id, sub: saved.id, email: saved.email }); + if (err.name === 'QueryFailedError') { + errorMessage = 'Database error occurred during registration'; + this.logger.error('DB_ERROR during registration', err); + } + + throw new CustomHttpException(errorMessage, statusCode); + } finally { + await queryRunner.release(); + } + + // Issued after commit so a DB rollback does not leave a dangling OTP in Redis or send a spurious email. + // Failure here is non-fatal — the user was created and can request a resend via /resend-otp. + try { + await this.issueOtp(createUserDto.email); + } catch (otpError) { + const err = otpError as Error; + this.logger.error(`OTP dispatch failed after registration: ${err.message}`, err.stack); + } return { status_code: HttpStatus.CREATED, message: SYS_MSG.USER_CREATED_SUCCESSFULLY, - access_token, data: { + redirect_url: '/dashboard', user: { id: saved.id, full_name: saved.full_name, @@ -170,10 +222,16 @@ export default class AuthenticationService { throw new CustomHttpException(SYS_MSG.INVALID_OTP, HttpStatus.BAD_REQUEST); } - user.is_verified = true; - user.otp_code = null; - user.expires_at = null; - await this.userRepository.save(user); + try { + user.is_verified = true; + user.otp_code = null; + user.expires_at = null; + await this.userRepository.save(user); + } catch (error) { + const err = error as Error; + this.logger.error(`OTP verification failed: ${err.message}`, err.stack); + throw new CustomHttpException(SYS_MSG.SESSION_CREATION_FAILED, HttpStatus.INTERNAL_SERVER_ERROR); + } await Promise.all([ this.redisService.del(`otp:${email}`), @@ -181,13 +239,21 @@ export default class AuthenticationService { this.redisService.del(`limit:${email}`), ]); - const access_token = this.jwtService.sign({ id: user.id, sub: user.id, email: user.email }); + const { rawToken, sessionId } = await this.sessionService.create(user); + + const access_token = this.jwtService.sign({ + id: user.id, + sub: user.id, + sid: sessionId, + email: user.email, + }); return { status_code: HttpStatus.OK, message: SYS_MSG.EMAIL_VERIFIED, - access_token, data: { + access_token, + refresh_token: rawToken, user: { id: user.id, full_name: user.full_name, diff --git a/src/modules/auth/docs/auth-swagger.doc.ts b/src/modules/auth/docs/auth-swagger.doc.ts index 30eec96..ccfa72d 100644 --- a/src/modules/auth/docs/auth-swagger.doc.ts +++ b/src/modules/auth/docs/auth-swagger.doc.ts @@ -4,6 +4,7 @@ import { SendOtpDto } from '../dto/send-otp.dto'; import { VerifyOtpDto } from '../dto/verify-otp.dto'; import { LoginDto } from '../dto/login.dto'; import { ChangePasswordDto } from '../dto/change-password.dto'; +import { CreateUserDTO } from '../dto/create-user.dto'; export function SendOtpDocs() { return applyDecorators( @@ -53,6 +54,16 @@ export function LoginDocs() { ); } +export function RegisterDocs() { + return applyDecorators( + HttpCode(HttpStatus.CREATED), + ApiOperation({ summary: 'Register a new user' }), + ApiBody({ type: CreateUserDTO }), + ApiResponse({ status: HttpStatus.CREATED, description: 'User Created Successfully' }), + ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Account with the specified email exists' }) + ); +} + export function ChangePasswordDocs() { return applyDecorators( ApiBearerAuth(), diff --git a/src/modules/auth/dto/create-user.dto.ts b/src/modules/auth/dto/create-user.dto.ts index a861060..12737da 100644 --- a/src/modules/auth/dto/create-user.dto.ts +++ b/src/modules/auth/dto/create-user.dto.ts @@ -1,5 +1,14 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsOptional, IsString, IsStrongPassword, MaxLength, MinLength } from 'class-validator'; +import { + IsBoolean, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + IsStrongPassword, + MaxLength, + MinLength, +} from 'class-validator'; export class CreateUserDTO { @ApiProperty({ @@ -43,4 +52,13 @@ export class CreateUserDTO { } ) password: string; + + @ApiProperty({ + description: 'The user must accept the Terms and Conditions of the application to continue with signup', + example: true, + required: true, + }) + @IsBoolean() + @IsNotEmpty() + terms_accepted: boolean; } diff --git a/src/modules/auth/session.service.ts b/src/modules/auth/session.service.ts index 0d6ee3f..9c55fa7 100644 --- a/src/modules/auth/session.service.ts +++ b/src/modules/auth/session.service.ts @@ -5,14 +5,13 @@ import { randomBytes, createHash } from 'crypto'; import { User } from '@modules/user/entities/user.entity'; import { UserSession } from './entities/user-session.entity'; -const REFRESH_TOKEN_EXPIRY_DAYS = 7; - +const REFRESH_TOKEN_EXPIRY_DAYS = +process.env.REFRESH_TOKEN_EXPIRY @Injectable() export class SessionService { constructor( @InjectRepository(UserSession) private readonly repo: Repository - ) {} + ) { } async create(user: User): Promise<{ rawToken: string; sessionId: string }> { const rawToken = randomBytes(32).toString('hex'); diff --git a/src/modules/auth/tests/auth.service.spec.ts b/src/modules/auth/tests/auth.service.spec.ts index c111659..ce66bcd 100644 --- a/src/modules/auth/tests/auth.service.spec.ts +++ b/src/modules/auth/tests/auth.service.spec.ts @@ -12,6 +12,10 @@ import QueueService from '@modules/email/queue.service'; import AuthenticationService from '../auth.service'; import { LockoutService } from '../lockout.service'; import { SessionService } from '../session.service'; +import { UserSession } from '../entities/user-session.entity'; +import { DataSource } from 'typeorm'; +import { AuthMetadata } from '../entities/auth-metadata.entity'; +import { Response } from 'express'; describe('AuthenticationService', () => { let service: AuthenticationService; @@ -26,6 +30,10 @@ describe('AuthenticationService', () => { exists: jest.fn().mockResolvedValue(false), expire: jest.fn().mockResolvedValue(undefined), }; + const userSessionRepositoryMock = { + create: jest.fn(), + save: jest.fn(), + }; const queueServiceMock = { sendMail: jest.fn().mockResolvedValue({ jobId: 'mock-job' }), } @@ -37,14 +45,41 @@ describe('AuthenticationService', () => { clear: jest.fn(), }; const sessionServiceMock = { create: jest.fn() }; + const authMetadataRepositoryMock = { + create: jest.fn(), + save: jest.fn(), + }; + + const dataSourceMock = { + createQueryRunner: jest.fn().mockReturnValue({ + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + create: jest.fn().mockImplementation((entity, data) => data), + save: jest + .fn() + .mockResolvedValue({ id: 'user-1', email: 'jane@example.com', full_name: 'Jane Doe', avatar_url: null }), + }, + }), + }; + + const responseMock = { + cookie: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AuthenticationService, { provide: getRepositoryToken(User), useValue: userRepositoryMock }, + { provide: getRepositoryToken(UserSession), useValue: userSessionRepositoryMock }, + { provide: getRepositoryToken(AuthMetadata), useValue: authMetadataRepositoryMock }, { provide: JwtService, useValue: jwtServiceMock }, { provide: RedisService, useValue: redisServiceMock }, + { provide: DataSource, useValue: dataSourceMock }, { provide: QueueService, useValue: queueServiceMock }, { provide: LockoutService, useValue: lockoutServiceMock }, { provide: SessionService, useValue: sessionServiceMock }, @@ -70,6 +105,7 @@ describe('AuthenticationService', () => { full_name: 'Jane Doe', password: 'P@ssword123', country: 'Nigeria', + terms_accepted: true, }; it('creates a user and dispatches OTP email — no plaintext OTP in DB', async () => { @@ -87,7 +123,6 @@ describe('AuthenticationService', () => { expect(result.status_code).toBe(HttpStatus.CREATED); expect(result.message).toBe(SYS_MSG.USER_CREATED_SUCCESSFULLY); - expect(result.access_token).toBe('jwt'); expect(result.data.user).toEqual({ id: 'user-1', full_name: dto.full_name, @@ -114,11 +149,22 @@ describe('AuthenticationService', () => { }), }) ); + expect(responseMock.cookie).toHaveBeenCalledWith( + 'refresh_token', + expect.any(String), + expect.objectContaining({ + httpOnly: true, + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, + }) + ); }); it('throws when a user with that email already exists', async () => { userRepositoryMock.findOne.mockResolvedValueOnce({ id: 'existing' }); - await expect(service.createNewUser(dto)).rejects.toThrow(CustomHttpException); + await expect(service.createNewUser(dto)).rejects.toThrow( + CustomHttpException + ); }); }); @@ -248,7 +294,6 @@ describe('AuthenticationService', () => { expect(result.status_code).toBe(HttpStatus.OK); expect(result.message).toBe(SYS_MSG.EMAIL_VERIFIED); - expect(result.access_token).toBe('jwt'); // DB otp_code and expires_at must be cleared const saved = userRepositoryMock.save.mock.calls[0][0]; diff --git a/src/modules/user/entities/user.entity.ts b/src/modules/user/entities/user.entity.ts index bb3a777..b9442fe 100644 --- a/src/modules/user/entities/user.entity.ts +++ b/src/modules/user/entities/user.entity.ts @@ -37,6 +37,9 @@ export class User extends AbstractBaseEntity { @Column({ type: 'timestamp', nullable: true }) expires_at: Date | null; + @Column({ type: 'boolean', nullable: false, default: false }) + terms_accepted: boolean; + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamp', nullable: true }) deleted_at: Date | null; } diff --git a/src/shared/constants/SystemMessages.ts b/src/shared/constants/SystemMessages.ts index 3a456ea..db41047 100644 --- a/src/shared/constants/SystemMessages.ts +++ b/src/shared/constants/SystemMessages.ts @@ -4,6 +4,7 @@ export const FAILED_TO_CREATE_USER = 'Error Occured while creating user, kindly export const ERROR_OCCURED = 'Error Occured Performing this request'; export const USER_ACCOUNT_EXIST = 'Account with the specified email exists'; export const USER_ACCOUNT_DOES_NOT_EXIST = "Account with the specified email doesn't exist"; +export const USER_ACCOUNT_LOCKED = 'Account with the specified email is locked'; export const UNAUTHENTICATED_MESSAGE = 'User is currently unauthorized, kindly authenticate to continue'; export const TWO_FACTOR_VERIFIED_SUCCESSFULLY = '2FA verified and enabled'; export const ANALYTICS_FETCHED_SUCCESSFULLY = 'Admin Analytics fetched successfully'; @@ -138,3 +139,5 @@ export const REDIS_MESSAGES = { PATTERN_DELETE_SUCCESS: (count: number, pattern: string) => `delByPattern: deleted ${count} keys matching [${pattern}]`, }; +export const TERMS_AND_CONDITIONS = 'You must accept the terms and conditions to register'; +export const SESSION_CREATION_FAILED = 'Registration failed. Please try again';