diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 3216a2f..323d075 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -9,7 +9,8 @@ on: jobs: verify: runs-on: ubuntu-latest - + environment: Tests + steps: - name: Checkout repository uses: actions/checkout@v2 @@ -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 diff --git a/backend/.gitignore b/backend/.gitignore index 9576ae9..19733a4 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -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 @@ -56,4 +52,4 @@ pids report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Prisma -prisma/.database.* +*.sqlite diff --git a/backend/package-lock.json b/backend/package-lock.json index a21e65a..4fbf79b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,10 +11,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" }, @@ -1895,6 +1898,15 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", + "integrity": "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "passport": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.5.tgz", @@ -7070,6 +7082,42 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-http": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/passport-http/-/passport-http-0.3.0.tgz", + "integrity": "sha512-OwK9DkqGVlJfO8oD0Bz1VDIo+ijD3c1ZbGGozIZw+joIP0U60pXY7goB+8wiDWtNqHpkTaQiJ9Ux1jE3Ykmpuw==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7141,6 +7189,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index e27a015..a486696 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" }, diff --git a/backend/src/api/user/user.controller.spec.ts b/backend/src/api/user/user.controller.spec.ts new file mode 100644 index 0000000..771f68c --- /dev/null +++ b/backend/src/api/user/user.controller.spec.ts @@ -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); + }); + + 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: 'max@mustermann.de', + password: '1234', + verified: true, + enabled: true, + }; + + const req = { + user, + }; + + const response = (await controller.me(req)) as any; + + expect(response?.password).toBeUndefined(); + }); +}); diff --git a/backend/src/api/user/user.controller.ts b/backend/src/api/user/user.controller.ts new file mode 100644 index 0000000..2b3fc90 --- /dev/null +++ b/backend/src/api/user/user.controller.ts @@ -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; + +@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); + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8662803..dd63c62 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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 {} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..edb94c8 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -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 {} diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..a50c17e --- /dev/null +++ b/backend/src/auth/auth.service.spec.ts @@ -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; + + const exampleUser = { + id: '0', + email: 'max@example.org', + 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()) + .compile(); + + service = module.get(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('max@example.org', '1234'), + ).toBeDefined(); + }); + + it('should return null if the user is invalid', async () => { + userRepository.findByEmail.mockResolvedValue(null); + + expect( + await service.validateUserPassword('max@example.org', '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('max@example.org', 'incorrect'), + ).toBeNull(); + }); +}); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..c72cbed --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -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 { + const user = await this.userRepository.findByEmail(username); + + if (user && (await bcrypt.compare(password, user.password))) { + return user; + } + + return null; + } +} diff --git a/backend/src/auth/auto.guard.ts b/backend/src/auth/auto.guard.ts new file mode 100644 index 0000000..bc20661 --- /dev/null +++ b/backend/src/auth/auto.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class AutoGuard extends AuthGuard('basic') {} diff --git a/backend/src/auth/strategies/http.strategy.spec.ts b/backend/src/auth/strategies/http.strategy.spec.ts new file mode 100644 index 0000000..9c306fe --- /dev/null +++ b/backend/src/auth/strategies/http.strategy.spec.ts @@ -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; + 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('max@example.org', '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('max@example.org', '1234'); + }).rejects.toThrow(); + }); +}); diff --git a/backend/src/auth/strategies/http.strategy.ts b/backend/src/auth/strategies/http.strategy.ts new file mode 100644 index 0000000..561e7a2 --- /dev/null +++ b/backend/src/auth/strategies/http.strategy.ts @@ -0,0 +1,32 @@ +import { PassportStrategy } from '@nestjs/passport'; +import { AuthService } from '../auth.service'; +import { User } from '@prisma/client'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { BasicStrategy } from 'passport-http'; + +@Injectable() +export class HTTPStrategy extends PassportStrategy(BasicStrategy) { + constructor(private authService: AuthService) { + super(); + } + + /** + * Validates username and password via the Authentication Service + * + * @param username + * @param password + * @returns + */ + async validate(username: string, password: string): Promise { + const user = await this.authService.validateUserPassword( + username, + password, + ); + + if (!user) { + throw new UnauthorizedException(); + } + + return user; + } +} diff --git a/backend/src/db/prisma.module.ts b/backend/src/db/prisma.module.ts new file mode 100644 index 0000000..72d1cd8 --- /dev/null +++ b/backend/src/db/prisma.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { UserRepository } from './repositories/user.repository'; +import { PrismaService } from './prisma.service'; + +@Module({ + providers: [PrismaService, UserRepository], + exports: [PrismaService, UserRepository], +}) +export class PrismaModule {} diff --git a/backend/src/db/repositories/user.repository.ts b/backend/src/db/repositories/user.repository.ts index 0126273..e1d40b3 100644 --- a/backend/src/db/repositories/user.repository.ts +++ b/backend/src/db/repositories/user.repository.ts @@ -6,6 +6,12 @@ import { PrismaService } from '../prisma.service'; export class UserRepository { constructor(private prisma: PrismaService) {} + /** + * Returns a user by his email adresss + * + * @param email + * @returns + */ public async findByEmail(email: string): Promise { return await this.prisma.user.findUnique({ where: { @@ -14,6 +20,14 @@ export class UserRepository { }); } + /** + * Creates a new user + * + * @param email Valid mail address, must be unique + * @param displayName Display Name choosen by the user + * @param password Hashed Password + * @returns User + */ public async createUser( email: string, displayName: string, @@ -28,6 +42,12 @@ export class UserRepository { }); } + /** + * Updates a user + * + * @param user + * @returns Updated User + */ public async updateUser(user: User): Promise { return await this.prisma.user.update({ where: { @@ -37,6 +57,12 @@ export class UserRepository { }); } + /** + * Deletes a user + * + * @param where + * @returns Deleted User + */ public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise { return await this.prisma.user.delete({ where, diff --git a/backend/test/lib/constants.ts b/backend/test/lib/constants.ts new file mode 100644 index 0000000..de83ff9 --- /dev/null +++ b/backend/test/lib/constants.ts @@ -0,0 +1,5 @@ +import { Users } from './database.constants'; + +export const TestConstants = { + database: { users: Users }, +}; diff --git a/backend/test/lib/database.constants.ts b/backend/test/lib/database.constants.ts new file mode 100644 index 0000000..95d2ed2 --- /dev/null +++ b/backend/test/lib/database.constants.ts @@ -0,0 +1,10 @@ +export const Users = { + exampleUser: { + id: '0', + email: 'max@example.org', + displayName: 'Max Mustermann', + password: '$2a$04$7r2EqdYAla6UUw9rxMlfc.pACgmONvT/0jFLC2xTLdrIvoxOGmzAC', // 1234 + enabled: true, + verified: false, + }, +};