Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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