base authentication

This commit is contained in:
rubikscraft 2022-02-21 14:53:21 +01:00
parent 21f7aa3187
commit d9e7ae06fb
No known key found for this signature in database
GPG key ID: 1463EBE9200A5CD4
22 changed files with 1483 additions and 446 deletions

18
dev/docker-compose.yml Normal file
View file

@ -0,0 +1,18 @@
version: "3"
services:
devdb:
image: postgres:11-alpine
environment:
POSTGRES_DB: imagur
POSTGRES_PASSWORD: imagur
POSTGRES_USER: imagur
logging:
driver: "none"
ports:
- "5432:5432"
restart: unless-stopped
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:

View file

@ -13,23 +13,39 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"devdb:start": "podman-compose -f ./dev/docker-compose.yml up -d",
"devdb:stop": "podman-compose -f ./dev/docker-compose.yml down",
"format": "prettier --write \"src/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
},
"dependencies": {
"@nestjs/common": "^8.1.1",
"@nestjs/core": "^8.1.1",
"@nestjs/platform-express": "^8.1.1",
"@nestjs/jwt": "^8.0.0",
"@nestjs/passport": "^8.2.1",
"@nestjs/platform-fastify": "^8.3.1",
"@nestjs/typeorm": "^8.0.3",
"bcrypt": "^5.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"passport": "^0.5.2",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"pg": "^8.7.3",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.4.0"
"rxjs": "^7.4.0",
"typeorm": "^0.2.43"
},
"devDependencies": {
"@nestjs/cli": "^8.1.3",
"@nestjs/schematics": "^8.0.4",
"@nestjs/testing": "^8.1.1",
"@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.13",
"@types/node": "^16.11.1",
"@types/passport-jwt": "^3.0.6",
"@types/passport-local": "^1.0.34",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2",

View file

@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View file

@ -1,10 +1,27 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import { UserEntity } from './users/user.entity';
import { UsersModule } from './users/users.module';
import Config from './env';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: Config.database.host,
port: Config.database.port,
username: Config.database.username,
password: Config.database.password,
database: Config.database.database,
autoLoadEntities: true,
synchronize: true,
entities: [UserEntity],
}),
AuthModule,
UsersModule,
],
})
export class AppModule {}

View file

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

27
src/auth/admin.guard.ts Normal file
View file

@ -0,0 +1,27 @@
import {
Injectable,
CanActivate,
ExecutionContext,
Logger,
} from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';
import { User } from 'src/users/user.dto';
@Injectable()
export class AdminGuard implements CanActivate {
private readonly logger = new Logger('AdminGuard');
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = plainToClass(User, request.user);
const errors = await validate(user);
if (errors.length > 0) {
this.logger.warn(`Invalid user payload: ${JSON.stringify(request.user)}`);
return false;
}
return user.isAdmin;
}
}

View file

@ -0,0 +1,71 @@
import {
Controller,
Post,
UseGuards,
Request,
Body,
Get,
} from '@nestjs/common';
import { LocalAuthGuard } from './local-auth.guard';
import {
RegisterRequestDto,
LoginResponseDto,
DeleteRequestDto,
} from './auth.dto';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt.guard';
import { AdminGuard } from './admin.guard';
import { Nothing, AsyncMaybe, Maybe } from 'src/lib/maybe';
import { User } from 'src/users/user.dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post('login')
async login(@Request() req) {
const response: LoginResponseDto = {
access_token: await this.authService.createToken(req.user),
};
return response;
}
@UseGuards(JwtAuthGuard, AdminGuard)
@Post('create')
async register(@Request() req, @Body() register: RegisterRequestDto) {
const user = await this.authService.createUser(
register.username,
register.password,
);
if (user === Nothing) {
throw new Error('User already exists');
}
if (register.isAdmin) {
await this.authService.makeAdmin(user);
}
return this.authService.userEntityToUser(user);
}
@UseGuards(JwtAuthGuard, AdminGuard)
@Post('delete')
async delete(@Request() req, @Body() deleteData: DeleteRequestDto) {
const user = await this.authService.deleteUser(deleteData.username);
if (user === Nothing) {
throw new Error('User does not exist');
}
return this.authService.userEntityToUser(user);
}
@UseGuards(JwtAuthGuard, AdminGuard)
@Get('list')
async listUsers(@Request() req) {
return this.authService.listUsers();
}
}

38
src/auth/auth.dto.ts Normal file
View file

@ -0,0 +1,38 @@
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { User } from 'src/users/user.dto';
export class LoginResponseDto {
@IsString()
access_token: string;
}
export class RegisterRequestDto {
@IsString()
@IsNotEmpty()
username: string;
@IsString()
@IsNotEmpty()
password: string;
@IsBoolean()
@IsOptional()
isAdmin?: boolean;
}
export class DeleteRequestDto {
@IsString()
@IsNotEmpty()
username: string;
}
export class JwtDataDto {
@ValidateNested()
user: User;
}

49
src/auth/auth.module.ts Normal file
View file

@ -0,0 +1,49 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from 'src/users/users.module';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { AuthController } from './auth.controller';
import Config from 'src/env';
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: Config.jwt.secret,
signOptions: { expiresIn: Config.jwt.expiresIn },
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
controllers: [AuthController],
})
export class AuthModule implements OnModuleInit {
private readonly logger = new Logger('AuthModule');
constructor(private authService: AuthService) {}
onModuleInit() {
this.checkJwtSecret();
this.ensureAdminExists();
}
private checkJwtSecret() {
if (Config.jwt.secret === 'CHANGE_ME') {
this.logger.error(
"JWT secret is not set. Please set the 'JWT_SECRET' environment variable.",
);
}
}
private async ensureAdminExists() {
const admin = Config.defaultAdmin;
this.logger.debug(`Ensuring admin user ${admin.username} exists`);
await this.authService.createUser(admin.username, admin.password);
await this.authService.makeAdmin(admin.username);
}
}

69
src/auth/auth.service.ts Normal file
View file

@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { AsyncMaybe, Nothing } from 'src/lib/maybe';
import { User } from 'src/users/user.dto';
import { UserEntity } from 'src/users/user.entity';
import { UsersService } from 'src/users/users.service';
import { JwtDataDto } from './auth.dto';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) {}
async createUser(username: string, password: string): AsyncMaybe<UserEntity> {
const hashedPassword = await bcrypt.hash(password, 12);
return this.usersService.createUser(username, hashedPassword);
}
async deleteUser(user: string | UserEntity): AsyncMaybe<UserEntity> {
return this.usersService.removeUser(user);
}
async listUsers(): Promise<User[]> {
const users = await this.usersService.findAll();
return users.map((user) => this.userEntityToUser(user));
}
async authenticate(
username: string,
password: string,
): AsyncMaybe<UserEntity> {
const user = await this.usersService.findOne(username);
if (user === Nothing) return Nothing;
if (!(await bcrypt.compare(password, user.password))) return Nothing;
return user;
}
async createToken(user: UserEntity): Promise<string> {
const jwtData: JwtDataDto = {
user: {
username: user.username,
isAdmin: user.isAdmin,
},
};
return this.jwtService.signAsync(jwtData);
}
async makeAdmin(user: string | UserEntity): Promise<boolean> {
return this.usersService.modifyAdmin(user, true);
}
async revokeAdmin(user: string | UserEntity): Promise<boolean> {
return this.usersService.modifyAdmin(user, false);
}
userEntityToUser(user: UserEntity): User {
return {
username: user.username,
isAdmin: user.isAdmin,
};
}
}

5
src/auth/jwt.guard.ts Normal file
View file

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

33
src/auth/jwt.strategy.ts Normal file
View file

@ -0,0 +1,33 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import Config from 'src/env';
import { validate } from 'class-validator';
import { JwtDataDto } from './auth.dto';
import { plainToClass } from 'class-transformer';
import { User } from 'src/users/user.dto';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly logger = new Logger('JwtStrategy');
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: Config.jwt.secret,
});
}
async validate(payload: any): Promise<User> {
const jwt = plainToClass(JwtDataDto, payload);
const errors = await validate(jwt);
if (errors.length > 0) {
this.logger.warn(`Invalid JWT payload: ${JSON.stringify(payload)}`);
throw new UnauthorizedException();
}
return jwt.user;
}
}

View file

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

View file

@ -0,0 +1,21 @@
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AsyncMaybe, Nothing } from 'src/lib/maybe';
import { UserEntity } from 'src/users/user.entity';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, password: string): AsyncMaybe<UserEntity> {
const user = await this.authService.authenticate(username, password);
if (user === Nothing) {
throw new UnauthorizedException();
}
return user;
}
}

19
src/env.ts Normal file
View file

@ -0,0 +1,19 @@
const Config = {
database: {
host: process.env.DB_HOST ?? 'localhost',
port: parseInt(process.env.DB_PORT) ?? 5432,
username: process.env.DB_USERNAME ?? 'imagur',
password: process.env.DB_PASSWORD ?? 'imagur',
database: process.env.DB_DATABASE ?? 'imagur',
},
defaultAdmin: {
username: process.env.DEFAULT_ADMIN_USERNAME ?? 'admin',
password: process.env.DEFAULT_ADMIN_PASSWORD ?? 'admin',
},
jwt: {
secret: process.env.JWT_SECRET ?? 'CHANGE_ME',
expiresIn: process.env.JWT_EXPIRES_IN ?? '1d',
},
};
export default Config;

5
src/lib/maybe.ts Normal file
View file

@ -0,0 +1,5 @@
export const Nothing = undefined;
export type Nothing = undefined;
export type Maybe<T> = T | Nothing;
export type AsyncMaybe<T> = Promise<Maybe<T>>;

View file

@ -1,8 +1,17 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
app.useGlobalPipes(new ValidationPipe({ disableErrorMessages: true }));
await app.listen(3000);
}

10
src/users/user.dto.ts Normal file
View file

@ -0,0 +1,10 @@
import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
export class User {
@IsString()
@IsNotEmpty()
username: string;
@IsBoolean()
isAdmin: boolean;
}

16
src/users/user.entity.ts Normal file
View file

@ -0,0 +1,16 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class UserEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
username: string;
@Column()
password: string;
@Column({ default: false })
isAdmin: boolean;
}

11
src/users/users.module.ts Normal file
View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './user.entity';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View file

@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AsyncMaybe, Nothing } from 'src/lib/maybe';
import { Not, Repository } from 'typeorm';
import { UserEntity } from './user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(UserEntity)
private usersRepository: Repository<UserEntity>,
) {}
async createUser(
username: string,
hashedPassword: string,
): AsyncMaybe<UserEntity> {
if (await this.exists(username)) return Nothing;
const user = new UserEntity();
user.username = username;
user.password = hashedPassword;
await this.usersRepository.save(user);
return user;
}
async removeUser(user: string | UserEntity): AsyncMaybe<UserEntity> {
const userToModify = await this.resolveUser(user);
if (user === Nothing) return Nothing;
await this.usersRepository.remove(userToModify);
return userToModify;
}
async findOne(username: string): AsyncMaybe<UserEntity> {
return await this.usersRepository.findOne({ where: { username } });
}
async findAll(): Promise<UserEntity[]> {
return await this.usersRepository.find();
}
async exists(username: string): Promise<boolean> {
return (await this.findOne(username)) !== Nothing;
}
async modifyAdmin(
user: string | UserEntity,
admin: boolean,
): Promise<boolean> {
const userToModify = await this.resolveUser(user);
if (userToModify === Nothing) return false;
userToModify.isAdmin = admin;
await this.usersRepository.save(userToModify);
return true;
}
private async resolveUser(user: string | UserEntity): Promise<UserEntity> {
if (typeof user === 'string') {
return await this.findOne(user);
} else {
return user;
}
}
}

1381
yarn.lock

File diff suppressed because it is too large Load diff