diff --git a/drips/src/auth/auth.controller.ts b/drips/src/auth/auth.controller.ts new file mode 100644 index 0000000..9731d68 --- /dev/null +++ b/drips/src/auth/auth.controller.ts @@ -0,0 +1,195 @@ +import { + Controller, + Post, + Get, + Body, + HttpCode, + HttpStatus, + Ip, + Headers, + Query, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { AuthService } from './auth.service'; +import { UserService } from './services/user.service'; +import { RegisterDto } from './dto/register.dto'; +import { LoginDto } from './dto/login.dto'; +import { AuthResponseDto } from './dto/auth-response.dto'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; +import { RequestVerificationDto } from './dto/request-verification.dto'; +import { VerifyEmailDto } from './dto/verify-email.dto'; +import { RequestPasswordResetDto } from './dto/request-password-reset.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; +import { CreateUserDto } from './dto/create-user.dto'; +import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import { UserResponseDto } from './dto/user-response.dto'; +import { ListUsersResponseDto } from './dto/list-users-response.dto'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly userService: UserService, + ) {} + + @Post('register') + @ApiOperation({ summary: 'Register a new user' }) + @ApiResponse({ + status: 201, + description: 'User successfully registered', + type: AuthResponseDto, + }) + @ApiResponse({ + status: 409, + description: 'User with this email already exists', + }) + @ApiResponse({ + status: 400, + description: 'Validation error', + }) + async register(@Body() registerDto: RegisterDto): Promise { + return this.authService.register(registerDto); + } + + @Post('login') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Login with email and password' }) + @ApiResponse({ + status: 200, + description: 'User successfully logged in', + type: AuthResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Invalid email or password', + }) + @ApiResponse({ + status: 400, + description: 'Validation error', + }) + async login( + @Body() loginDto: LoginDto, + @Ip() ip: string, + @Headers('user-agent') userAgent?: string, + ): Promise { + return this.authService.login(loginDto, ip, userAgent); + } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Refresh access token' }) + @ApiResponse({ status: 200, description: 'Tokens refreshed' }) + @ApiResponse({ status: 401, description: 'Invalid or expired refresh token' }) + async refresh( + @Body() dto: RefreshTokenDto, + @Ip() ip: string, + @Headers('user-agent') userAgent?: string, + ) { + return this.authService.rotateRefreshToken(dto.refreshToken, ip, userAgent); + } + + @Post('logout') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Logout and revoke refresh token' }) + @ApiResponse({ status: 200, description: 'Logged out successfully' }) + async logout(@Body() dto: RefreshTokenDto) { + await this.authService.revokeRefreshToken(dto.refreshToken); + return { message: 'Logged out successfully' }; + } + + @Post('request-verification') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Request email verification' }) + @ApiResponse({ + status: 200, + description: 'Verification email sent if user exists', + }) + async requestVerification(@Body() dto: RequestVerificationDto) { + await this.authService.requestVerification(dto); + return { message: 'Verification email sent' }; + } + + @Post('verify-email') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify email with token' }) + @ApiResponse({ status: 200, description: 'Email verified successfully' }) + @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + async verifyEmail(@Body() dto: VerifyEmailDto) { + await this.authService.verifyEmail(dto); + return { message: 'Email verified successfully' }; + } + + @Post('request-password-reset') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Request password reset' }) + @ApiResponse({ status: 200, description: 'Reset email sent if user exists' }) + async requestPasswordReset(@Body() dto: RequestPasswordResetDto) { + await this.authService.requestPasswordReset(dto); + return { message: 'Password reset email sent' }; + } + + @Post('reset-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reset password with token' }) + @ApiResponse({ status: 200, description: 'Password reset successfully' }) + @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + async resetPassword(@Body() dto: ResetPasswordDto) { + await this.authService.resetPassword(dto); + return { message: 'Password reset successfully' }; + } + + // ========== TEMPORARY ADMIN-ONLY ENDPOINTS ========== + + @Post('admin/users') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Create a new user (temporary admin endpoint for testing)', + }) + @ApiResponse({ + status: 201, + description: 'User created successfully', + type: UserResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Validation error or user already exists', + }) + async createUser(@Body() createUserDto: CreateUserDto): Promise { + const user = await this.userService.createUser( + createUserDto.email, + createUserDto.password, + createUserDto.roles, + createUserDto.status, + createUserDto.firstName, + createUserDto.lastName, + ); + return new UserResponseDto(user); + } + + @Get('admin/users') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'List all users with pagination (temporary admin endpoint for testing)', + }) + @ApiResponse({ + status: 200, + description: 'Users retrieved successfully', + type: ListUsersResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Invalid pagination parameters', + }) + async listUsers( + @Query() queryDto: ListUsersQueryDto, + ): Promise { + const { users, total, page, limit } = await this.userService.listUsers( + queryDto.page, + queryDto.limit, + ); + + const userDtos = users.map((user) => new UserResponseDto(user)); + return new ListUsersResponseDto(userDtos, total, page, limit); + } +} diff --git a/drips/src/auth/auth.module.ts b/drips/src/auth/auth.module.ts new file mode 100644 index 0000000..2562b8f --- /dev/null +++ b/drips/src/auth/auth.module.ts @@ -0,0 +1,57 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; +import { User } from '../users/entities/user.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { EmailVerificationToken } from './entities/email-verification-token.entity'; +import { PasswordResetToken } from './entities/password-reset-token.entity'; +import { MockMailer } from '../../../libs/common/src/mailer/mock-mailer'; +import { UserService } from './services/user.service'; +import { UserRepository } from './repositories/user.repository'; + +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + +@Module({ + imports: [ + ConfigModule, + TypeOrmModule.forFeature([ + RefreshToken, + User, + EmailVerificationToken, + PasswordResetToken, + ]), + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.register({}), // Config is per-token in the service + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: parseInt( + configService.get('JWT_TTL') || '3600', + 10, + ), + }, + }), + }), + ConfigModule, + ], + controllers: [AuthController], + providers: [ + AuthService, + JwtStrategy, + JwtRefreshStrategy, + MockMailer, + UserService, + UserRepository, + ], + exports: [AuthService, JwtStrategy, PassportModule, UserService], +}) +export class AuthModule {} diff --git a/drips/src/auth/auth.service.spec.ts b/drips/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..e6e7d0c --- /dev/null +++ b/drips/src/auth/auth.service.spec.ts @@ -0,0 +1,537 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConflictException, UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { AuthService } from './auth.service'; +import { User } from '../users/entities/user.entity'; +import { RegisterDto } from './dto/register.dto'; +import { LoginDto } from './dto/login.dto'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { EmailVerificationToken } from './entities/email-verification-token.entity'; +import { PasswordResetToken } from './entities/password-reset-token.entity'; +import { MockMailer } from '../../../libs/common/src/mailer/mock-mailer'; +import { EmailVerificationToken } from './entities/email-verification-token.entity'; +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ import { PasswordResetToken } from './entities/password-reset-token.entity'; +import { MockMailer } from '../../../libs/common/src/mailer/mock-mailer'; +import * as crypto from 'crypto'; + +describe('AuthService', () => { + let service: AuthService; + + const mockUser = { + id: '123e4567-e89b-12d3-a456-426614174000', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + password_hash: '$2b$10$hashedpassword', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as User; + + const mockUserRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockRefreshTokenRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + }; + + const mockEmailVerificationTokenRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }; + + const mockPasswordResetTokenRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }; + + const mockMailer = { + send: jest.fn(), + }; + + const mockJwtService = { + signAsync: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn((key: string) => { + const config: Record = { + JWT_ACCESS_SECRET: 'test-access-secret', + JWT_ACCESS_TTL: '15m', + JWT_REFRESH_TTL: '604800', + }; + return config[key]; + }), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: getRepositoryToken(RefreshToken), + useValue: mockRefreshTokenRepository, + }, + { + provide: getRepositoryToken(EmailVerificationToken), + useValue: mockEmailVerificationTokenRepository, + }, + { + provide: getRepositoryToken(PasswordResetToken), + useValue: mockPasswordResetTokenRepository, + }, + { + provide: JwtService, + useValue: mockJwtService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: MockMailer, + useValue: mockMailer, + }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('register', () => { + const registerDto: RegisterDto = { + email: 'newuser@example.com', + password: 'SecurePass123!', + firstName: 'Jane', + lastName: 'Smith', + }; + + it('should successfully register and return tokens', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + mockUserRepository.create.mockReturnValue(mockUser); + mockUserRepository.save.mockResolvedValue(mockUser); + mockJwtService.signAsync.mockResolvedValue('access-token'); + mockRefreshTokenRepository.create.mockReturnValue({ id: 'token-id' }); + mockRefreshTokenRepository.save.mockResolvedValue({}); + + const result = await service.register(registerDto); + + expect(result.tokens).toBeDefined(); + expect(result.tokens.accessToken).toBe('access-token'); + expect(result.tokens.refreshToken).toBeDefined(); + expect(mockRefreshTokenRepository.save).toHaveBeenCalled(); + }); + + it('should throw ConflictException if user exists', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + await expect(service.register(registerDto)).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe('login', () => { + const loginDto: LoginDto = { + email: 'test@example.com', + password: 'SecurePass123!', + }; + + it('should login and return tokens', async () => { + const hashedPassword = await bcrypt.hash(loginDto.password, 10); + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + password_hash: hashedPassword, + }); + mockJwtService.signAsync.mockResolvedValue('access-token'); + mockRefreshTokenRepository.create.mockReturnValue({}); + mockRefreshTokenRepository.save.mockResolvedValue({}); + + const result = await service.login(loginDto); + + expect(result.tokens.accessToken).toBe('access-token'); + expect(mockRefreshTokenRepository.save).toHaveBeenCalled(); + }); + + it('should throw UnauthorizedException for invalid password', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + await expect(service.login(loginDto)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('rotateRefreshToken', () => { + const oldToken = 'old-token'; + const oldTokenHash = crypto + .createHash('sha256') + .update(oldToken) + .digest('hex'); + + it('should rotate refresh token successfully (old refresh becomes invalid)', async () => { + const tokenRecord = { + userId: mockUser.id, + user: mockUser, + tokenHash: oldTokenHash, + expiresAt: new Date(Date.now() + 10000), + revokedAt: null, + } as unknown as RefreshToken; + + mockRefreshTokenRepository.findOne.mockResolvedValue(tokenRecord); + mockRefreshTokenRepository.create.mockReturnValue({}); + mockRefreshTokenRepository.save.mockResolvedValue({}); + mockJwtService.signAsync.mockResolvedValue('new-access-token'); + + const result = await service.rotateRefreshToken(oldToken); + + expect(result.accessToken).toBe('new-access-token'); + expect(tokenRecord.revokedAt).toBeInstanceOf(Date); + expect(tokenRecord.replacedByTokenHash).toBeDefined(); + expect(mockRefreshTokenRepository.save).toHaveBeenCalled(); + }); + + it('should detect reuse and revoke all tokens if already revoked (replay detection)', async () => { + const tokenRecord = { + userId: mockUser.id, + tokenHash: oldTokenHash, + revokedAt: new Date(), + } as unknown as RefreshToken; + + mockRefreshTokenRepository.findOne.mockResolvedValue(tokenRecord); + + await expect(service.rotateRefreshToken(oldToken)).rejects.toThrow( + UnauthorizedException, + ); + expect(mockRefreshTokenRepository.update).toHaveBeenCalledWith( + { userId: mockUser.id, revokedAt: undefined }, + { revokedAt: expect.any(Date) as Date }, + ); + }); + + it('should detect reuse if replacedByTokenHash is set (replay detection)', async () => { + const tokenRecord = { + userId: mockUser.id, + tokenHash: oldTokenHash, + replacedByTokenHash: 'some-hash', + } as unknown as RefreshToken; + + mockRefreshTokenRepository.findOne.mockResolvedValue(tokenRecord); + + await expect(service.rotateRefreshToken(oldToken)).rejects.toThrow( + UnauthorizedException, + ); + expect(mockRefreshTokenRepository.update).toHaveBeenCalled(); + }); + + it('should fail if token expired', async () => { + const tokenRecord = { + userId: mockUser.id, + tokenHash: oldTokenHash, + expiresAt: new Date(Date.now() - 10000), + } as unknown as RefreshToken; + + mockRefreshTokenRepository.findOne.mockResolvedValue(tokenRecord); + + await expect(service.rotateRefreshToken(oldToken)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('revokeRefreshToken (Logout)', () => { + it('should revoke the token if found', async () => { + const token = 'active-token'; + const tokenRecord = { + tokenHash: 'some-hash', + revokedAt: null, + } as unknown as RefreshToken; + + mockRefreshTokenRepository.findOne.mockResolvedValue(tokenRecord); + mockRefreshTokenRepository.save.mockResolvedValue({}); + + await service.revokeRefreshToken(token); + + expect(tokenRecord.revokedAt).toBeInstanceOf(Date); + expect(mockRefreshTokenRepository.save).toHaveBeenCalledWith(tokenRecord); + }); + + it('should subsequent refresh fail after logout (implicit by revokedAt check)', async () => { + const token = 'active-token'; + const tokenRecord = { + tokenHash: 'some-hash', + revokedAt: null, + } as unknown as RefreshToken; + + mockRefreshTokenRepository.findOne.mockResolvedValue(tokenRecord); + await service.revokeRefreshToken(token); + + expect(tokenRecord.revokedAt).toBeInstanceOf(Date); + }); + }); + + describe('Token store integrity', () => { + it('should save hashed tokens in DB not plain text', async () => { + const token = 'plain-text-token'; + mockUserRepository.findOne.mockResolvedValue(null); + mockUserRepository.create.mockReturnValue(mockUser); + mockUserRepository.save.mockResolvedValue(mockUser); + mockJwtService.signAsync.mockResolvedValue('access'); + mockRefreshTokenRepository.create.mockImplementation( + (dto: unknown) => dto, + ); + mockRefreshTokenRepository.save.mockResolvedValue({}); + + await service.register({ + email: 't@t.com', + password: 'p', + firstName: 'f', + lastName: 'l', + }); + + const createCall = mockRefreshTokenRepository.create.mock.calls[0] as [ + { tokenHash: string }, + ]; + const savedTokenHash = createCall[0].tokenHash; + expect(savedTokenHash).not.toBe(token); + expect(savedTokenHash).toHaveLength(64); // SHA256 length + }); + }); +}); + +describe('Email Verification', () => { + let service: AuthService; + + const mockUser = { + id: '123e4567-e89b-12d3-a456-426614174000', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + password_hash: '$2b$10$hashedpassword', + isActive: true, + emailVerifiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as User; + + const mockUserRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockEmailVerificationTokenRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }; + + const mockMailer = { + send: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: getRepositoryToken(EmailVerificationToken), + useValue: mockEmailVerificationTokenRepository, + }, + { + provide: MockMailer, + useValue: mockMailer, + }, + { + provide: ConfigService, + useValue: { get: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + it('should send verification email for existing user', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockEmailVerificationTokenRepository.create.mockReturnValue({}); + mockEmailVerificationTokenRepository.save.mockResolvedValue({}); + + await service.requestVerification({ email: 'test@example.com' }); + + expect(mockMailer.send).toHaveBeenCalledWith( + 'test@example.com', + 'Verify your email', + expect.stringContaining('verify-email?token='), + ); + }); + + it('should not send email for non-existing user', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await service.requestVerification({ email: 'nonexistent@example.com' }); + + expect(mockMailer.send).not.toHaveBeenCalled(); + }); + + it('should verify email with valid token', async () => { + const token = 'a'.repeat(64); + const tokenEntity = { + id: '1', + tokenHash: 'hashed', + expiresAt: new Date(Date.now() + 1000), + user: mockUser, + }; + + mockEmailVerificationTokenRepository.findOne.mockResolvedValue(tokenEntity); + mockUserRepository.save.mockResolvedValue({ + ...mockUser, + emailVerifiedAt: new Date(), + }); + + await service.verifyEmail({ token }); + + expect(mockUser.emailVerifiedAt).toBeInstanceOf(Date); + expect(mockEmailVerificationTokenRepository.delete).toHaveBeenCalledWith( + '1', + ); + }); + + it('should throw error for invalid token', async () => { + mockEmailVerificationTokenRepository.findOne.mockResolvedValue(null); + + await expect(service.verifyEmail({ token: 'invalid' })).rejects.toThrow( + 'Invalid or expired token', + ); + }); +}); + +describe('Password Reset', () => { + let service: AuthService; + + const mockUser = { + id: '123e4567-e89b-12d3-a456-426614174000', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + password_hash: '$2b$10$hashedpassword', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as User; + + const mockUserRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const mockPasswordResetTokenRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }; + + const mockRefreshTokenRepository = { + update: jest.fn(), + }; + + const mockMailer = { + send: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: getRepositoryToken(PasswordResetToken), + useValue: mockPasswordResetTokenRepository, + }, + { + provide: getRepositoryToken(RefreshToken), + useValue: mockRefreshTokenRepository, + }, + { + provide: MockMailer, + useValue: mockMailer, + }, + { + provide: ConfigService, + useValue: { get: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + it('should send reset email for existing user', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockPasswordResetTokenRepository.create.mockReturnValue({}); + mockPasswordResetTokenRepository.save.mockResolvedValue({}); + + await service.requestPasswordReset({ email: 'test@example.com' }); + + expect(mockMailer.send).toHaveBeenCalledWith( + 'test@example.com', + 'Reset your password', + expect.stringContaining('reset-password?token='), + ); + }); + + it('should reset password with valid token', async () => { + const token = 'a'.repeat(64); + const tokenEntity = { + id: '1', + tokenHash: 'hashed', + expiresAt: new Date(Date.now() + 1000), + user: mockUser, + }; + + mockPasswordResetTokenRepository.findOne.mockResolvedValue(tokenEntity); + mockUserRepository.save.mockResolvedValue(mockUser); + + await service.resetPassword({ token, newPassword: 'newpass' }); + + expect(mockUser.password_hash).not.toBe('$2b$10$hashedpassword'); + expect(mockPasswordResetTokenRepository.delete).toHaveBeenCalledWith('1'); + expect(mockRefreshTokenRepository.update).toHaveBeenCalled(); + }); + + it('should throw error for invalid reset token', async () => { + mockPasswordResetTokenRepository.findOne.mockResolvedValue(null); + + await expect( + service.resetPassword({ token: 'invalid', newPassword: 'new' }), + ).rejects.toThrow('Invalid or expired token'); + }); +}); diff --git a/drips/src/auth/auth.service.ts b/drips/src/auth/auth.service.ts new file mode 100644 index 0000000..247db57 --- /dev/null +++ b/drips/src/auth/auth.service.ts @@ -0,0 +1,362 @@ +import { + Injectable, + ConflictException, + UnauthorizedException, + Logger, + BadRequestException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import { User } from '../users/entities/user.entity'; +import { RegisterDto } from './dto/register.dto'; +import { LoginDto } from './dto/login.dto'; +import { AuthResponseDto, AuthTokensDto } from './dto/auth-response.dto'; +import { JwtPayload } from './strategies/jwt.strategy'; +import * as crypto from 'crypto'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { EmailVerificationToken } from './entities/email-verification-token.entity'; +import { PasswordResetToken } from './entities/password-reset-token.entity'; +import { MockMailer } from '../../../libs/common/src/mailer/mock-mailer'; +import { TokenUtil } from '../../../libs/common/src/utils/token.util'; +import { RequestVerificationDto } from './dto/request-verification.dto'; +import { VerifyEmailDto } from './dto/verify-email.dto'; +import { RequestPasswordResetDto } from './dto/request-password-reset.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; + +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + private readonly BCRYPT_SALT_ROUNDS = 10; + private readonly TOKEN_EXPIRY_HOURS = 24; // for verification and reset + + constructor( + @InjectRepository(User) + private userRepository: Repository, + @InjectRepository(RefreshToken) + private refreshTokenRepository: Repository, + @InjectRepository(EmailVerificationToken) + private emailVerificationTokenRepository: Repository, + @InjectRepository(PasswordResetToken) + private passwordResetTokenRepository: Repository, + private jwtService: JwtService, + private configService: ConfigService, + private mailer: MockMailer, + ) {} + + // Helper: Hash token + private hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } + + private async sendVerificationEmail(user: User): Promise { + const token = TokenUtil.generateToken(); + const tokenHash = TokenUtil.hashToken(token); + const expiresAt = new Date( + Date.now() + this.TOKEN_EXPIRY_HOURS * 60 * 60 * 1000, + ); + + const verificationToken = this.emailVerificationTokenRepository.create({ + userId: user.id, + tokenHash, + expiresAt, + }); + + await this.emailVerificationTokenRepository.save(verificationToken); + + const subject = 'Verify your email'; + const html = `Click here to verify: http://localhost:3000/auth/verify-email?token=${token}`; + + await this.mailer.send(user.email, subject, html); + } + + async requestVerification(dto: RequestVerificationDto): Promise { + const user = await this.userRepository.findOne({ + where: { email: dto.email }, + }); + if (!user) { + // Don't leak if user exists + return; + } + if (user.emailVerifiedAt) { + return; + } + await this.sendVerificationEmail(user); + } + + async verifyEmail(dto: VerifyEmailDto): Promise { + const tokenHash = TokenUtil.hashToken(dto.token); + + const tokenEntity = await this.emailVerificationTokenRepository.findOne({ + where: { tokenHash }, + relations: ['user'], + }); + + if (!tokenEntity || TokenUtil.isExpired(tokenEntity.expiresAt)) { + throw new BadRequestException('Invalid or expired token'); + } + + const user = tokenEntity.user; + if (user.emailVerifiedAt) { + throw new BadRequestException('Email already verified'); + } + + user.emailVerifiedAt = new Date(); + await this.userRepository.save(user); + + // Delete the token + await this.emailVerificationTokenRepository.delete(tokenEntity.id); + } + + async requestPasswordReset(dto: RequestPasswordResetDto): Promise { + const user = await this.userRepository.findOne({ + where: { email: dto.email }, + }); + if (!user) { + // Don't leak + return; + } + + const token = TokenUtil.generateToken(); + const tokenHash = TokenUtil.hashToken(token); + const expiresAt = new Date( + Date.now() + this.TOKEN_EXPIRY_HOURS * 60 * 60 * 1000, + ); + + const resetToken = this.passwordResetTokenRepository.create({ + userId: user.id, + tokenHash, + expiresAt, + }); + + await this.passwordResetTokenRepository.save(resetToken); + + const subject = 'Reset your password'; + const html = `Click here to reset: http://localhost:3000/auth/reset-password?token=${token}`; + + await this.mailer.send(user.email, subject, html); + } + + async resetPassword(dto: ResetPasswordDto): Promise { + const tokenHash = TokenUtil.hashToken(dto.token); + + const tokenEntity = await this.passwordResetTokenRepository.findOne({ + where: { tokenHash }, + relations: ['user'], + }); + + if (!tokenEntity || TokenUtil.isExpired(tokenEntity.expiresAt)) { + throw new BadRequestException('Invalid or expired token'); + } + + const user = tokenEntity.user; + const newPasswordHash = await bcrypt.hash( + dto.newPassword, + this.BCRYPT_SALT_ROUNDS, + ); + + user.password_hash = newPasswordHash; + await this.userRepository.save(user); + + // Delete the token + await this.passwordResetTokenRepository.delete(tokenEntity.id); + + // Revoke all sessions + await this.revokeAllUserTokens(user.id); + } + + async register(registerDto: RegisterDto): Promise { + const { email, password, firstName, lastName } = registerDto; + + const existingUser = await this.userRepository.findOne({ + where: { email }, + }); + + if (existingUser) { + throw new ConflictException('User with this email already exists'); + } + + const password_hash = await bcrypt.hash(password, this.BCRYPT_SALT_ROUNDS); + + const user = this.userRepository.create({ + email, + firstName, + lastName, + password_hash, + }); + + const savedUser = await this.userRepository.save(user); + + // Send verification email + await this.sendVerificationEmail(savedUser); + + const tokens = await this.generateTokens(savedUser); + + return { + id: savedUser.id, + email: savedUser.email, + firstName: savedUser.firstName, + lastName: savedUser.lastName, + tokens, + }; + } + + async login( + loginDto: LoginDto, + ip?: string, + userAgent?: string, + ): Promise { + const { email, password } = loginDto; + + const user = await this.userRepository.findOne({ + where: { email }, + }); + + if (!user || !user.password_hash) { + throw new UnauthorizedException('Invalid email or password'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password_hash); + + if (!isPasswordValid) { + throw new UnauthorizedException('Invalid email or password'); + } + + if (!user.isActive) { + throw new UnauthorizedException('Account is inactive'); + } + + if (!user.emailVerifiedAt) { + throw new UnauthorizedException('Email not verified'); + } + + const tokens = await this.generateTokens(user, ip, userAgent); + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + tokens, + }; + } + + async rotateRefreshToken( + oldToken: string, + ip?: string, + userAgent?: string, + ): Promise { + const oldTokenHash = this.hashToken(oldToken); + + const tokenRecord = await this.refreshTokenRepository.findOne({ + where: { tokenHash: oldTokenHash }, + relations: ['user'], + }); + + if (!tokenRecord) { + throw new UnauthorizedException('Invalid refresh token'); + } + + if (tokenRecord.revokedAt) { + this.logger.warn( + `Refresh token reuse detected for user ${tokenRecord.userId}. Revoking all tokens.`, + ); + await this.revokeAllUserTokens(tokenRecord.userId); + throw new UnauthorizedException('Refresh token reused - Security alert'); + } + + if (tokenRecord.expiresAt < new Date()) { + throw new UnauthorizedException('Refresh token expired'); + } + + if (tokenRecord.replacedByTokenHash) { + this.logger.warn( + `Refresh token reuse (replaced) detected for user ${tokenRecord.userId}. Revoking all tokens.`, + ); + await this.revokeAllUserTokens(tokenRecord.userId); + throw new UnauthorizedException('Refresh token reused - Security alert'); + } + + // Generate new tokens + const tokens = await this.generateTokens(tokenRecord.user, ip, userAgent); + const newTokenHash = this.hashToken(tokens.refreshToken); + + // Update old token + tokenRecord.revokedAt = new Date(); + tokenRecord.replacedByTokenHash = newTokenHash; + await this.refreshTokenRepository.save(tokenRecord); + + return tokens; + } + + async revokeRefreshToken(token: string): Promise { + const tokenHash = this.hashToken(token); + const tokenRecord = await this.refreshTokenRepository.findOne({ + where: { tokenHash }, + }); + + if (!tokenRecord) { + return; + } + + tokenRecord.revokedAt = new Date(); + await this.refreshTokenRepository.save(tokenRecord); + } + + async revokeAllUserTokens(userId: string): Promise { + await this.refreshTokenRepository.update( + { userId, revokedAt: undefined }, + { revokedAt: new Date() }, + ); + } + + private async generateTokens( + user: User, + ip?: string, + userAgent?: string, + ): Promise { + const accessTokenPayload: JwtPayload = { + sub: user.id, + email: user.email, + type: 'access', + }; + + const accessSecret = this.configService.get('JWT_ACCESS_SECRET'); + const accessTtl = this.configService.get('JWT_ACCESS_TTL'); + const refreshTtl = + this.configService.get('JWT_REFRESH_TTL') || '604800'; + + if (!accessSecret) { + throw new Error('JWT_ACCESS_SECRET is not configured'); + } + + const accessToken = await this.jwtService.signAsync(accessTokenPayload, { + secret: accessSecret, + expiresIn: accessTtl || '15m', + }); + + // Generate Opaque Refresh Token + const refreshToken = crypto.randomBytes(32).toString('hex'); + const refreshTokenHash = this.hashToken(refreshToken); + const expiresAt = new Date(Date.now() + parseInt(refreshTtl, 10) * 1000); + + const refreshTokenRecord = this.refreshTokenRepository.create({ + userId: user.id, + tokenHash: refreshTokenHash, + expiresAt, + ip, + userAgent, + }); + + await this.refreshTokenRepository.save(refreshTokenRecord); + + return { + accessToken, + refreshToken, + }; + } +} diff --git a/drips/src/auth/decorators/rbac.guard.ts b/drips/src/auth/decorators/rbac.guard.ts new file mode 100644 index 0000000..91fdb65 --- /dev/null +++ b/drips/src/auth/decorators/rbac.guard.ts @@ -0,0 +1,48 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RbacGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + // If no roles are required, allow access + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const request = context.switchToHttp().getRequest(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const user = request.user; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const userRoles: string[] | undefined = user?.roles; + + // Roles are required but user has none + if (!userRoles || userRoles.length === 0) { + throw new ForbiddenException(); + } + + const hasRequiredRole = requiredRoles.some((role) => + userRoles.includes(role), + ); + + if (!hasRequiredRole) { + throw new ForbiddenException(); + } + + return true; + } +} diff --git a/drips/src/auth/decorators/roles.decorator.ts b/drips/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..e7eea58 --- /dev/null +++ b/drips/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; + +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/drips/src/auth/dto/assign-roles.dto.ts b/drips/src/auth/dto/assign-roles.dto.ts new file mode 100644 index 0000000..4807933 --- /dev/null +++ b/drips/src/auth/dto/assign-roles.dto.ts @@ -0,0 +1,10 @@ +import { IsArray, IsEnum, IsOptional } from 'class-validator'; +import { UserRole } from '@libs/common'; +import { Type } from 'class-transformer'; + +export class AssignRolesDto { + @IsArray() + @IsEnum(UserRole, { each: true }) + @Type(() => String) + roles!: UserRole[]; +} diff --git a/drips/src/auth/dto/auth-response.dto.ts b/drips/src/auth/dto/auth-response.dto.ts new file mode 100644 index 0000000..ec9312c --- /dev/null +++ b/drips/src/auth/dto/auth-response.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AuthTokensDto { + @ApiProperty({ + description: 'JWT access token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + accessToken!: string; + + @ApiProperty({ + description: 'JWT refresh token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + refreshToken!: string; +} + +export class AuthResponseDto { + @ApiProperty({ + description: 'User ID', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + id!: string; + + @ApiProperty({ + description: 'User email', + example: 'john.doe@example.com', + }) + email!: string; + + @ApiProperty({ + description: 'User first name', + example: 'John', + }) + firstName!: string; + + @ApiProperty({ + description: 'User last name', + example: 'Doe', + }) + lastName!: string; + + @ApiProperty({ + description: 'Authentication tokens', + type: AuthTokensDto, + }) + tokens!: AuthTokensDto; +} diff --git a/drips/src/auth/dto/create-user.dto.ts b/drips/src/auth/dto/create-user.dto.ts new file mode 100644 index 0000000..a8156a2 --- /dev/null +++ b/drips/src/auth/dto/create-user.dto.ts @@ -0,0 +1,29 @@ +import { IsEmail, IsArray, IsEnum, IsOptional, MinLength } from 'class-validator'; +import { UserRole, UserStatus } from '@libs/common'; +import { Type } from 'class-transformer'; + +export class CreateUserDto { + @IsEmail() + email!: string; + + @MinLength(8, { + message: 'Password must be at least 8 characters long', + }) + password!: string; + + @IsOptional() + @IsArray() + @IsEnum(UserRole, { each: true }) + @Type(() => String) + roles?: UserRole[]; + + @IsOptional() + @IsEnum(UserStatus) + status?: UserStatus; + + @IsOptional() + firstName?: string; + + @IsOptional() + lastName?: string; +} diff --git a/drips/src/auth/dto/list-users-query.dto.ts b/drips/src/auth/dto/list-users-query.dto.ts new file mode 100644 index 0000000..dfa4e9f --- /dev/null +++ b/drips/src/auth/dto/list-users-query.dto.ts @@ -0,0 +1,17 @@ +import { IsOptional, Min, Max, IsNumber } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ListUsersQueryDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 10; +} diff --git a/drips/src/auth/dto/list-users-response.dto.ts b/drips/src/auth/dto/list-users-response.dto.ts new file mode 100644 index 0000000..02e8a05 --- /dev/null +++ b/drips/src/auth/dto/list-users-response.dto.ts @@ -0,0 +1,22 @@ +import { UserResponseDto } from './user-response.dto'; + +export class ListUsersResponseDto { + users!: UserResponseDto[]; + total!: number; + page!: number; + limit!: number; + totalPages!: number; + + constructor( + users: UserResponseDto[], + total: number, + page: number, + limit: number, + ) { + this.users = users; + this.total = total; + this.page = page; + this.limit = limit; + this.totalPages = Math.ceil(total / limit); + } +} diff --git a/drips/src/auth/dto/login.dto.ts b/drips/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..2ab75dc --- /dev/null +++ b/drips/src/auth/dto/login.dto.ts @@ -0,0 +1,20 @@ +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LoginDto { + @ApiProperty({ + description: 'User email address', + example: 'john.doe@example.com', + }) + @IsEmail() + @IsNotEmpty() + email!: string; + + @ApiProperty({ + description: 'User password', + example: 'SecurePass123!', + }) + @IsString() + @IsNotEmpty() + password!: string; +} diff --git a/drips/src/auth/dto/refresh-token.dto.ts b/drips/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..c2c1ae4 --- /dev/null +++ b/drips/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RefreshTokenDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + refreshToken!: string; +} diff --git a/drips/src/auth/dto/register.dto.ts b/drips/src/auth/dto/register.dto.ts new file mode 100644 index 0000000..fab6c8e --- /dev/null +++ b/drips/src/auth/dto/register.dto.ts @@ -0,0 +1,38 @@ +import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RegisterDto { + @ApiProperty({ + description: 'User first name', + example: 'John', + }) + @IsString() + @IsNotEmpty() + firstName!: string; + + @ApiProperty({ + description: 'User last name', + example: 'Doe', + }) + @IsString() + @IsNotEmpty() + lastName!: string; + + @ApiProperty({ + description: 'User email address', + example: 'john.doe@example.com', + }) + @IsEmail() + @IsNotEmpty() + email!: string; + + @ApiProperty({ + description: 'User password (minimum 8 characters)', + example: 'SecurePass123!', + minLength: 8, + }) + @IsString() + @IsNotEmpty() + @MinLength(8, { message: 'Password must be at least 8 characters long' }) + password!: string; +} diff --git a/drips/src/auth/dto/request-password-reset.dto.ts b/drips/src/auth/dto/request-password-reset.dto.ts new file mode 100644 index 0000000..f463e0d --- /dev/null +++ b/drips/src/auth/dto/request-password-reset.dto.ts @@ -0,0 +1,6 @@ +import { IsEmail } from 'class-validator'; + +export class RequestPasswordResetDto { + @IsEmail() + email!: string; +} diff --git a/drips/src/auth/dto/request-verification.dto.ts b/drips/src/auth/dto/request-verification.dto.ts new file mode 100644 index 0000000..22daf82 --- /dev/null +++ b/drips/src/auth/dto/request-verification.dto.ts @@ -0,0 +1,6 @@ +import { IsEmail } from 'class-validator'; + +export class RequestVerificationDto { + @IsEmail() + email!: string; +} diff --git a/drips/src/auth/dto/reset-password.dto.ts b/drips/src/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..3cb3427 --- /dev/null +++ b/drips/src/auth/dto/reset-password.dto.ts @@ -0,0 +1,11 @@ +import { IsString, Length, MinLength } from 'class-validator'; + +export class ResetPasswordDto { + @IsString() + @Length(64, 64) + token!: string; + + @IsString() + @MinLength(8) + newPassword!: string; +} diff --git a/drips/src/auth/dto/user-response.dto.ts b/drips/src/auth/dto/user-response.dto.ts new file mode 100644 index 0000000..4e4e726 --- /dev/null +++ b/drips/src/auth/dto/user-response.dto.ts @@ -0,0 +1,19 @@ +import { UserRole, UserStatus } from '@libs/common'; + +export class UserResponseDto { + id!: string; + email!: string; + firstName?: string; + lastName?: string; + avatarUrl?: string; + roles!: UserRole[]; + status!: UserStatus; + isActive!: boolean; + emailVerifiedAt?: Date; + createdAt!: Date; + updatedAt!: Date; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} diff --git a/drips/src/auth/dto/verify-email.dto.ts b/drips/src/auth/dto/verify-email.dto.ts new file mode 100644 index 0000000..50965f3 --- /dev/null +++ b/drips/src/auth/dto/verify-email.dto.ts @@ -0,0 +1,7 @@ +import { IsString, Length } from 'class-validator'; + +export class VerifyEmailDto { + @IsString() + @Length(64, 64) // since hex 32 bytes + token!: string; +} diff --git a/drips/src/auth/entities/email-verification-token.entity.ts b/drips/src/auth/entities/email-verification-token.entity.ts new file mode 100644 index 0000000..f781839 --- /dev/null +++ b/drips/src/auth/entities/email-verification-token.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('email_verification_tokens') +export class EmailVerificationToken { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + userId!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user!: User; + + @Column() + tokenHash!: string; + + @Column() + expiresAt!: Date; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/drips/src/auth/entities/password-reset-token.entity.ts b/drips/src/auth/entities/password-reset-token.entity.ts new file mode 100644 index 0000000..455f746 --- /dev/null +++ b/drips/src/auth/entities/password-reset-token.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('password_reset_tokens') +export class PasswordResetToken { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + userId!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user!: User; + + @Column() + tokenHash!: string; + + @Column() + expiresAt!: Date; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/drips/src/auth/entities/refresh-token.entity.ts b/drips/src/auth/entities/refresh-token.entity.ts new file mode 100644 index 0000000..47c5d93 --- /dev/null +++ b/drips/src/auth/entities/refresh-token.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('refresh_tokens') +export class RefreshToken { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + @Index() + userId!: string; + + @ManyToOne(() => User, (user) => user.id, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user!: User; + + @Column() + tokenHash!: string; + + @Column() + expiresAt!: Date; + + @Column({ nullable: true }) + revokedAt?: Date; + + @CreateDateColumn() + createdAt!: Date; + + @Column({ nullable: true }) + userAgent?: string; + + @Column({ nullable: true }) + ip?: string; + + @Column({ nullable: true }) + replacedByTokenHash?: string; // useful for tracking rotation chains +} diff --git a/drips/src/auth/guards/jwt-auth.guard.ts b/drips/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..f9516ee --- /dev/null +++ b/drips/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,9 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + canActivate(context: ExecutionContext) { + return super.canActivate(context); + } +} diff --git a/drips/src/auth/repositories/index.ts b/drips/src/auth/repositories/index.ts new file mode 100644 index 0000000..9fb5d34 --- /dev/null +++ b/drips/src/auth/repositories/index.ts @@ -0,0 +1 @@ +export * from './user.repository'; diff --git a/drips/src/auth/repositories/user.repository.ts b/drips/src/auth/repositories/user.repository.ts new file mode 100644 index 0000000..27449a6 --- /dev/null +++ b/drips/src/auth/repositories/user.repository.ts @@ -0,0 +1,168 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../entities/user.entity'; +import { UserRole, UserStatus } from '@libs/common'; + +@Injectable() +export class UserRepository { + constructor( + @InjectRepository(User) + private readonly repository: Repository, + ) {} + + /** + * Create a new user with normalized email + */ + async createUser( + email: string, + password_hash: string, + roles: UserRole[] = [UserRole.MENTEE], + status: UserStatus = UserStatus.PENDING, + firstName?: string, + lastName?: string, + ): Promise { + const normalizedEmail = email.toLowerCase(); + + const user = this.repository.create({ + email: normalizedEmail, + password_hash, + roles, + status, + firstName, + lastName, + }); + + return this.repository.save(user); + } + + /** + * Find user by email (case-insensitive) + */ + async findByEmail(email: string): Promise { + const normalizedEmail = email.toLowerCase(); + return this.repository.findOne({ + where: { email: normalizedEmail }, + }); + } + + /** + * Find user by ID + */ + async findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + }); + } + + /** + * List users with pagination + */ + async listUsers( + page: number = 1, + limit: number = 10, + ): Promise<{ users: User[]; total: number }> { + const skip = (page - 1) * limit; + const [users, total] = await this.repository.findAndCount({ + skip, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { users, total }; + } + + /** + * Update user status + */ + async updateStatus(userId: string, status: UserStatus): Promise { + await this.repository.update(userId, { status }); + const user = await this.findById(userId); + if (!user) { + throw new Error(`User with ID ${userId} not found`); + } + return user; + } + + /** + * Assign roles to user (overwrites existing roles) + */ + async assignRoles(userId: string, roles: UserRole[]): Promise { + if (!roles || roles.length === 0) { + throw new Error('At least one role must be assigned'); + } + + // Validate roles + const validRoles = Object.values(UserRole); + const invalidRoles = roles.filter((role) => !validRoles.includes(role)); + if (invalidRoles.length > 0) { + throw new Error(`Invalid roles: ${invalidRoles.join(', ')}`); + } + + await this.repository.update(userId, { roles }); + const user = await this.findById(userId); + if (!user) { + throw new Error(`User with ID ${userId} not found`); + } + return user; + } + + /** + * Add role to user (append to existing roles) + */ + async addRole(userId: string, role: UserRole): Promise { + const user = await this.findById(userId); + if (!user) { + throw new Error(`User with ID ${userId} not found`); + } + + if (!user.roles.includes(role)) { + user.roles = [...user.roles, role]; + return this.repository.save(user); + } + + return user; + } + + /** + * Remove role from user + */ + async removeRole(userId: string, role: UserRole): Promise { + const user = await this.findById(userId); + if (!user) { + throw new Error(`User with ID ${userId} not found`); + } + + if (user.roles.length === 1) { + throw new Error('User must have at least one role'); + } + + user.roles = user.roles.filter((r: UserRole) => r !== role); + return this.repository.save(user); + } + + /** + * Check if user has a specific role + */ + async hasRole(userId: string, role: UserRole): Promise { + const user = await this.findById(userId); + return user?.roles.includes(role) ?? false; + } + + /** + * Find all admins + */ + async findAdmins(): Promise { + return this.repository + .createQueryBuilder('user') + .where(':role = ANY(user.roles)', { role: UserRole.ADMIN }) + .getMany(); + } + + /** + * Delete user + */ + async deleteUser(userId: string): Promise { + await this.repository.delete(userId); + } +} diff --git a/drips/src/auth/services/index.ts b/drips/src/auth/services/index.ts new file mode 100644 index 0000000..d7c48e4 --- /dev/null +++ b/drips/src/auth/services/index.ts @@ -0,0 +1,2 @@ +export * from './repositories/user.repository'; +export * from './services/user.service'; diff --git a/drips/src/auth/services/user.service.ts b/drips/src/auth/services/user.service.ts new file mode 100644 index 0000000..4e446fd --- /dev/null +++ b/drips/src/auth/services/user.service.ts @@ -0,0 +1,215 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { UserRepository } from '../repositories/user.repository'; +import { User } from '../entities/user.entity'; +import { UserRole, UserStatus } from '@libs/common'; + +@Injectable() +export class UserService { + constructor(private readonly userRepository: UserRepository) {} + + /** + * Create a new user with validation + */ + async createUser( + email: string, + password: string, + roles?: UserRole[], + status?: UserStatus, + firstName?: string, + lastName?: string, + ): Promise { + // Validate email format + if (!this.isValidEmail(email)) { + throw new BadRequestException('Invalid email format'); + } + + // Normalize email + const normalizedEmail = email.toLowerCase(); + + // Check if user already exists + const existingUser = await this.userRepository.findByEmail(normalizedEmail); + if (existingUser) { + throw new BadRequestException('User with this email already exists'); + } + + // Validate roles if provided + if (roles && roles.length > 0) { + this.validateRoles(roles); + } else { + roles = [UserRole.MENTEE]; + } + + // Validate status if provided + if (status && !Object.values(UserStatus).includes(status)) { + throw new BadRequestException(`Invalid status: ${status}`); + } + + // Create user with normalized email and provided password (hashing is out of scope) + return this.userRepository.createUser( + normalizedEmail, + password, + roles, + status || UserStatus.PENDING, + firstName, + lastName, + ); + } + + /** + * Get user by ID + */ + async getUserById(userId: string): Promise { + const user = await this.userRepository.findById(userId); + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + return user; + } + + /** + * Get user by email + */ + async getUserByEmail(email: string): Promise { + const normalizedEmail = email.toLowerCase(); + const user = await this.userRepository.findByEmail(normalizedEmail); + if (!user) { + throw new NotFoundException(`User with email ${normalizedEmail} not found`); + } + return user; + } + + /** + * List all users with pagination + */ + async listUsers( + page: number = 1, + limit: number = 10, + ): Promise<{ users: User[]; total: number; page: number; limit: number }> { + // Validate pagination parameters + if (page < 1) { + throw new BadRequestException('Page must be greater than 0'); + } + if (limit < 1 || limit > 100) { + throw new BadRequestException('Limit must be between 1 and 100'); + } + + const { users, total } = await this.userRepository.listUsers(page, limit); + return { users, total, page, limit }; + } + + /** + * Update user status + */ + async updateUserStatus(userId: string, status: UserStatus): Promise { + if (!Object.values(UserStatus).includes(status)) { + throw new BadRequestException(`Invalid status: ${status}`); + } + + try { + return await this.userRepository.updateStatus(userId, status); + } catch (error) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + } + + /** + * Assign roles to user (overwrites existing roles) + */ + async assignRoles(userId: string, roles: UserRole[]): Promise { + if (!roles || roles.length === 0) { + throw new BadRequestException('At least one role must be assigned'); + } + + this.validateRoles(roles); + + try { + return await this.userRepository.assignRoles(userId, roles); + } catch (error) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + } + + /** + * Add a role to user + */ + async addRole(userId: string, role: UserRole): Promise { + if (!Object.values(UserRole).includes(role)) { + throw new BadRequestException(`Invalid role: ${role}`); + } + + try { + return await this.userRepository.addRole(userId, role); + } catch (error) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + } + + /** + * Remove a role from user + */ + async removeRole(userId: string, role: UserRole): Promise { + if (!Object.values(UserRole).includes(role)) { + throw new BadRequestException(`Invalid role: ${role}`); + } + + try { + return await this.userRepository.removeRole(userId, role); + } catch (error) { + if (error instanceof Error && error.message.includes('at least one role')) { + throw new BadRequestException('User must have at least one role'); + } + throw new NotFoundException(`User with ID ${userId} not found`); + } + } + + /** + * Check if user has a specific role + */ + async hasRole(userId: string, role: UserRole): Promise { + return this.userRepository.hasRole(userId, role); + } + + /** + * Check if user is admin + */ + async isAdmin(userId: string): Promise { + return this.hasRole(userId, UserRole.ADMIN); + } + + /** + * Get all admins + */ + async getAdmins(): Promise { + return this.userRepository.findAdmins(); + } + + /** + * Delete user + */ + async deleteUser(userId: string): Promise { + await this.userRepository.deleteUser(userId); + } + + // Helper methods + + /** + * Validate email format + */ + private isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + /** + * Validate roles + */ + private validateRoles(roles: UserRole[]): void { + const validRoles = Object.values(UserRole); + const invalidRoles = roles.filter((role) => !validRoles.includes(role)); + if (invalidRoles.length > 0) { + throw new BadRequestException( + `Invalid roles: ${invalidRoles.join(', ')}. Valid roles are: ${validRoles.join(', ')}`, + ); + } + } +} diff --git a/drips/src/auth/strategies/jwt-refresh.strategy.ts b/drips/src/auth/strategies/jwt-refresh.strategy.ts new file mode 100644 index 0000000..cd972b5 --- /dev/null +++ b/drips/src/auth/strategies/jwt-refresh.strategy.ts @@ -0,0 +1,30 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { JwtPayload } from './jwt.strategy'; + +@Injectable() +export class JwtRefreshStrategy extends PassportStrategy( + Strategy, + 'jwt-refresh', +) { + constructor(private configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_REFRESH_SECRET'), + }); + } + + async validate(payload: JwtPayload) { + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid token type'); + } + + return { + sub: payload.sub, + email: payload.email, + }; + } +} diff --git a/drips/src/auth/strategies/jwt.strategy.ts b/drips/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..77e1286 --- /dev/null +++ b/drips/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,32 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; + +export interface JwtPayload { + sub: string; + email: string; + type: 'access' | 'refresh'; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor(private configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_ACCESS_SECRET'), + }); + } + + async validate(payload: JwtPayload) { + if (payload.type !== 'access') { + throw new UnauthorizedException('Invalid token type'); + } + + return { + sub: payload.sub, + email: payload.email, + }; + } +}