Skip to content
This repository has been archived by the owner on Oct 4, 2024. It is now read-only.

Authentication & Initial "/user/me" implementation #100

Merged
merged 11 commits into from
Apr 16, 2024
6 changes: 5 additions & 1 deletion .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ on:
jobs:
verify:
runs-on: ubuntu-latest

environment: Tests

steps:
- name: Checkout repository
uses: actions/checkout@v2
Expand All @@ -24,5 +25,8 @@ jobs:
- name: Install dependencies
run: cd backend && npm install

- name: Reset the database (for e2e tests)
run: cd backend && npm run db:reset

- name: Run verify
run: cd backend && npm run verify
6 changes: 1 addition & 5 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ lerna-debug.log*

# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

# dotenv environment variable files
.env
Expand All @@ -56,4 +52,4 @@ pids
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Prisma
prisma/.database.*
*.sqlite
53 changes: 53 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.11.0",
"@types/bcrypt": "^5.0.2",
"bcrypt": "^5.1.1",
"passport": "^0.7.0",
"passport-http": "^0.3.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
Expand Down
38 changes: 38 additions & 0 deletions backend/src/api/user/user.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UserController } from './user.controller';
import { User } from '@prisma/client';

describe('UserController', () => {
let controller: UserController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UserController],
}).compile();

controller = module.get<UserController>(UserController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

it('should return a SanatizedUser object in a gest request', async () => {
const user: User = {
id: '1',
displayName: 'Max Mustermann',
email: '[email protected]',
password: '1234',
verified: true,
enabled: true,
};

const req = {
user,
};

const response = (await controller.me(req)) as any;

expect(response?.password).toBeUndefined();
});
});
29 changes: 29 additions & 0 deletions backend/src/api/user/user.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Controller, Get, Request, UseGuards } from '@nestjs/common';
import { User } from '@prisma/client';
import { AutoGuard } from '../../auth/auto.guard';

type SanatizedUser = Omit<User, 'password'>;

@Controller('user')
export class UserController {
_sanatizeUser(user: User): SanatizedUser {
const sanatizedUser: SanatizedUser & { password?: string } = user;

delete sanatizedUser.password;
return sanatizedUser;
}

/**
* /user/me
*
* Returns information about the current user
*
* @param req
* @returns
*/
@Get('/me')
@UseGuards(AutoGuard)
async me(@Request() req) {
return this._sanatizeUser(req.user);
}
}
6 changes: 4 additions & 2 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { UserController } from './api/user/user.controller';

@Module({
imports: [],
controllers: [AppController],
imports: [AuthModule],
controllers: [AppController, UserController],
providers: [AppService],
})
export class AppModule {}
12 changes: 12 additions & 0 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { PassportModule } from '@nestjs/passport';
import { HTTPStrategy } from './strategies/http.strategy';
import { PrismaModule } from '../db/prisma.module';
import { AutoGuard } from './auto.guard';

@Module({
imports: [PrismaModule, PassportModule],
providers: [AuthService, HTTPStrategy, AutoGuard],
})
export class AuthModule {}
60 changes: 60 additions & 0 deletions backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { DeepMockProxy, mockDeep } from 'jest-mock-extended';
import { hashSync } from 'bcrypt';
import { PrismaClient } from '@prisma/client';
import { UserRepository } from '../db/repositories/user.repository';

describe('AuthService', () => {
let service: AuthService;
let userRepository: DeepMockProxy<UserRepository>;

const exampleUser = {
id: '0',
email: '[email protected]',
displayName: 'Max Mustermann',
password: hashSync('1234', 1),
enabled: true,
verified: false,
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuthService, UserRepository, PrismaClient],
})
.overrideProvider(UserRepository)
.useValue(mockDeep<UserRepository>())
.compile();

service = module.get<AuthService>(AuthService);
userRepository = await module.resolve(UserRepository);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

it('should return a user when a valid email and password is supplied', async () => {
userRepository.findByEmail.mockResolvedValue(exampleUser);

expect(
await service.validateUserPassword('[email protected]', '1234'),
).toBeDefined();
});

it('should return null if the user is invalid', async () => {
userRepository.findByEmail.mockResolvedValue(null);

expect(
await service.validateUserPassword('[email protected]', '1234'),
).toBeNull();
});

it('should return null if the user is found, but the password is incorrect', async () => {
userRepository.findByEmail.mockResolvedValue(exampleUser);

expect(
await service.validateUserPassword('[email protected]', 'incorrect'),
).toBeNull();
});
});
30 changes: 30 additions & 0 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
import { compare } from 'bcrypt';
import { UserRepository } from '../db/repositories/user.repository';

@Injectable()
export class AuthService {
constructor(private userRepository: UserRepository) {}

/**
* Valides a user with a supplied password. This will return a user if both the username
* and the email is valid. If not, this will return null.
*
* @param username
* @param password
* @returns
*/
async validateUserPassword(
username: string,
password: string,
): Promise<User | null> {
const user = await this.userRepository.findByEmail(username);

if (user && (await bcrypt.compare(password, user.password))) {
return user;
}

return null;
}
}
5 changes: 5 additions & 0 deletions backend/src/auth/auto.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class AutoGuard extends AuthGuard('basic') {}
41 changes: 41 additions & 0 deletions backend/src/auth/strategies/http.strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from '../auth.service';
import { DeepMockProxy, mockDeep } from 'jest-mock-extended';
import { TestConstants } from '../../../test/lib/constants';
import { HTTPStrategy } from './http.strategy';

describe('HTTPStrategy tests', () => {
let authService: DeepMockProxy<AuthService>;
let cut: HTTPStrategy;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuthService],
})
.overrideProvider(AuthService)
.useValue(mockDeep(AuthService))
.compile();

authService = module.get(AuthService);

cut = new HTTPStrategy(authService);
});

it('should return the user if the credentials are correct', async () => {
authService.validateUserPassword.mockResolvedValue(
TestConstants.database.users.exampleUser,
);

expect(await cut.validate('[email protected]', '1234')).toEqual(
TestConstants.database.users.exampleUser,
);
});

it('should fail with an exception if the credentials are invalid', async () => {
authService.validateUserPassword.mockResolvedValue(null);

expect(async () => {
await cut.validate('[email protected]', '1234');
}).rejects.toThrow();
});
});
Loading
Loading