Refactoring

* Refactor workspace membership system
* Create setup endpoint
* Use Passport.js
* Several updates and fixes
This commit is contained in:
Philipinho
2024-03-16 22:58:12 +00:00
parent b42fe48e9b
commit a821e37028
87 changed files with 2703 additions and 2307 deletions

View File

@ -1,27 +1,47 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service';
import { CreateUserDto } from '../user/dto/create-user.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { SetupGuard } from './guards/setup.guard';
import { EnvironmentService } from '../../environment/environment.service';
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
constructor(
private authService: AuthService,
private environmentService: EnvironmentService,
) {}
@HttpCode(HttpStatus.OK)
@Post('login')
async login(@Body() loginInput: LoginDto) {
return await this.authService.login(loginInput);
async login(@Req() req, @Body() loginInput: LoginDto) {
return this.authService.login(loginInput, req.raw.workspaceId);
}
@HttpCode(HttpStatus.OK)
@Post('register')
async register(@Body() createUserDto: CreateUserDto) {
return await this.authService.register(createUserDto);
async register(@Req() req, @Body() createUserDto: CreateUserDto) {
return this.authService.register(createUserDto, req.raw.workspaceId);
}
@UseGuards(SetupGuard)
@HttpCode(HttpStatus.OK)
@Post('setup')
async setupWorkspace(
@Req() req,
@Body() createAdminUserDto: CreateAdminUserDto,
) {
if (this.environmentService.isCloud()) throw new NotFoundException();
return this.authService.setup(createAdminUserDto);
}
}

View File

@ -4,13 +4,17 @@ import { AuthService } from './services/auth.service';
import { JwtModule } from '@nestjs/jwt';
import { EnvironmentService } from '../../environment/environment.service';
import { TokenService } from './services/token.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { WorkspaceModule } from '../workspace/workspace.module';
import { SignupService } from './services/signup.service';
import { UserModule } from '../user/user.module';
import { SpaceModule } from '../space/space.module';
@Module({
imports: [
JwtModule.registerAsync({
useFactory: async (environmentService: EnvironmentService) => {
return {
global: true,
secret: environmentService.getJwtSecret(),
signOptions: {
expiresIn: environmentService.getJwtTokenExpiresIn(),
@ -18,10 +22,13 @@ import { TokenService } from './services/token.service';
};
},
inject: [EnvironmentService],
}),
} as any),
UserModule,
WorkspaceModule,
SpaceModule,
],
controllers: [AuthController],
providers: [AuthService, TokenService],
providers: [AuthService, SignupService, TokenService, JwtStrategy],
exports: [TokenService],
})
export class AuthModule {}

View File

@ -0,0 +1,8 @@
import * as bcrypt from 'bcrypt';
export async function comparePasswordHash(
plainPassword: string,
passwordHash: string,
): Promise<boolean> {
return bcrypt.compare(plainPassword, passwordHash);
}

View File

@ -0,0 +1,15 @@
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import { CreateUserDto } from './create-user.dto';
export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty()
@MinLength(3)
@MaxLength(35)
name: string;
@IsNotEmpty()
@MinLength(4)
@MaxLength(35)
@IsString()
workspaceName: string;
}

View File

@ -0,0 +1,24 @@
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString, MaxLength,
MinLength,
} from 'class-validator';
export class CreateUserDto {
@IsOptional()
@MinLength(3)
@MaxLength(35)
@IsString()
name: string;
@IsNotEmpty()
@IsEmail()
email: string;
@IsNotEmpty()
@MinLength(8)
@IsString()
password: string;
}

View File

@ -0,0 +1,16 @@
export enum JwtType {
ACCESS = 'access',
REFRESH = 'refresh',
}
export type JwtPayload = {
sub: string;
email: string;
workspaceId: string;
type: 'access';
};
export type JwtRefreshPayload = {
sub: string;
workspaceId: string;
type: 'refresh';
};

View File

@ -1,55 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { TokenService } from '../services/token.service';
import { UserService } from '../../user/user.service';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../../../decorators/public.decorator';
@Injectable()
export class JwtGuard implements CanActivate {
constructor(
private tokenService: TokenService,
private userService: UserService,
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const token: string = await this.tokenService.extractTokenFromHeader(
request,
);
if (!token) {
throw new UnauthorizedException('Invalid jwt token');
}
let payload;
try {
payload = await this.tokenService.verifyJwt(token);
} catch (error) {
throw new UnauthorizedException('Could not verify jwt token');
}
try {
//fetch user and current workspace data from db
request['user'] = await this.userService.getUserInstance(payload.sub);
} catch (error) {
throw new UnauthorizedException('Failed to fetch auth user');
}
return true;
}
}

View File

@ -0,0 +1,14 @@
import { CanActivate, ForbiddenException, Injectable } from '@nestjs/common';
import { WorkspaceRepository } from '../../workspace/repositories/workspace.repository';
@Injectable()
export class SetupGuard implements CanActivate {
constructor(private workspaceRepository: WorkspaceRepository) {}
async canActivate(): Promise<boolean> {
const workspaceCount = await this.workspaceRepository.count();
if (workspaceCount > 0) {
throw new ForbiddenException('Workspace setup already completed.');
}
return true;
}
}

View File

@ -1,38 +1,57 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { LoginDto } from '../dto/login.dto';
import { User } from '../../user/entities/user.entity';
import { CreateUserDto } from '../../user/dto/create-user.dto';
import { CreateUserDto } from '../dto/create-user.dto';
import { UserService } from '../../user/user.service';
import { TokenService } from './token.service';
import { TokensDto } from '../dto/tokens.dto';
import { UserRepository } from '../../user/repositories/user.repository';
import { comparePasswordHash } from '../auth.utils';
import { SignupService } from './signup.service';
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private signupService: SignupService,
private tokenService: TokenService,
private userRepository: UserRepository,
) {}
async login(loginDto: LoginDto) {
const user: User = await this.userService.findByEmail(loginDto.email);
const invalidCredentialsMessage = 'email or password does not match';
async login(loginDto: LoginDto, workspaceId: string) {
const user = await this.userRepository.findOneByEmail(
loginDto.email,
workspaceId,
);
if (
!user ||
!(await this.userService.compareHash(loginDto.password, user.password))
!(await comparePasswordHash(loginDto.password, user.password))
) {
throw new UnauthorizedException(invalidCredentialsMessage);
throw new UnauthorizedException('email or password does not match');
}
user.lastLoginAt = new Date();
await this.userRepository.save(user);
const tokens: TokensDto = await this.tokenService.generateTokens(user);
return { tokens };
}
async register(createUserDto: CreateUserDto, workspaceId: string) {
const user: User = await this.signupService.signup(
createUserDto,
workspaceId,
);
const tokens: TokensDto = await this.tokenService.generateTokens(user);
return { tokens };
}
async register(createUserDto: CreateUserDto) {
const user: User = await this.userService.create(createUserDto);
async setup(createAdminUserDto: CreateAdminUserDto) {
const user: User = await this.signupService.firstSetup(createAdminUserDto);
const tokens: TokensDto = await this.tokenService.generateTokens(user);

View File

@ -0,0 +1,121 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { CreateUserDto } from '../dto/create-user.dto';
import { DataSource, EntityManager } from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { transactionWrapper } from '../../../helpers/db.helper';
import { UserRepository } from '../../user/repositories/user.repository';
import { WorkspaceRepository } from '../../workspace/repositories/workspace.repository';
import { WorkspaceService } from '../../workspace/services/workspace.service';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
import { Workspace } from '../../workspace/entities/workspace.entity';
import { SpaceService } from '../../space/space.service';
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
@Injectable()
export class SignupService {
constructor(
private userRepository: UserRepository,
private workspaceRepository: WorkspaceRepository,
private workspaceService: WorkspaceService,
private spaceService: SpaceService,
private dataSource: DataSource,
) {}
prepareUser(createUserDto: CreateUserDto): User {
const user = new User();
user.name = createUserDto.name || createUserDto.email.split('@')[0];
user.email = createUserDto.email.toLowerCase();
user.password = createUserDto.password;
user.locale = 'en';
user.lastLoginAt = new Date();
return user;
}
async createUser(
createUserDto: CreateUserDto,
manager?: EntityManager,
): Promise<User> {
return await transactionWrapper(
async (transactionManager: EntityManager) => {
let user = this.prepareUser(createUserDto);
user = await transactionManager.save(user);
return user;
},
this.dataSource,
manager,
);
}
async signup(
createUserDto: CreateUserDto,
workspaceId: string,
manager?: EntityManager,
): Promise<User> {
const userCheck = await this.userRepository.findOneByEmail(
createUserDto.email,
workspaceId,
);
if (userCheck) {
throw new BadRequestException('You have an account on this workspace');
}
return await transactionWrapper(
async (manager: EntityManager) => {
// create user
const user = await this.createUser(createUserDto, manager);
// add user to workspace
await this.workspaceService.addUserToWorkspace(
user,
workspaceId,
undefined,
manager,
);
return user;
},
this.dataSource,
manager,
);
}
async createWorkspace(
user: User,
workspaceName,
manager?: EntityManager,
): Promise<Workspace> {
return await transactionWrapper(
async (manager: EntityManager) => {
// for cloud
const workspaceData: CreateWorkspaceDto = {
name: workspaceName,
// hostname: '', // generate
};
return await this.workspaceService.create(user, workspaceData, manager);
},
this.dataSource,
manager,
);
}
async firstSetup(
createAdminUserDto: CreateAdminUserDto,
manager?: EntityManager,
): Promise<User> {
return await transactionWrapper(
async (manager: EntityManager) => {
// create user
const user = await this.createUser(createAdminUserDto, manager);
await this.createWorkspace(
user,
createAdminUserDto.workspaceName,
manager,
);
return user;
},
this.dataSource,
manager,
);
}
}

View File

@ -2,10 +2,8 @@ import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../environment/environment.service';
import { User } from '../../user/entities/user.entity';
import { FastifyRequest } from 'fastify';
import { TokensDto } from '../dto/tokens.dto';
export type JwtPayload = { sub: string; email: string };
import { JwtPayload, JwtRefreshPayload, JwtType } from '../dto/jwt-payload';
@Injectable()
export class TokenService {
@ -13,31 +11,37 @@ export class TokenService {
private jwtService: JwtService,
private environmentService: EnvironmentService,
) {}
async generateJwt(user: User): Promise<string> {
async generateAccessToken(user: User): Promise<string> {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
workspaceId: user.workspaceId,
type: JwtType.ACCESS,
};
return await this.jwtService.signAsync(payload);
return this.jwtService.sign(payload);
}
async generateRefreshToken(userId: string, workspaceId): Promise<string> {
const payload: JwtRefreshPayload = {
sub: userId,
workspaceId,
type: JwtType.REFRESH,
};
const expiresIn = '30d'; // todo: fix
return this.jwtService.sign(payload, { expiresIn });
}
async generateTokens(user: User): Promise<TokensDto> {
return {
accessToken: await this.generateJwt(user),
refreshToken: null,
accessToken: await this.generateAccessToken(user),
refreshToken: await this.generateRefreshToken(user.id, user.workspaceId),
};
}
async verifyJwt(token: string) {
return await this.jwtService.verifyAsync(token, {
return this.jwtService.verifyAsync(token, {
secret: this.environmentService.getJwtSecret(),
});
}
async extractTokenFromHeader(
request: FastifyRequest,
): Promise<string | undefined> {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@ -0,0 +1,64 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../environment/environment.service';
import { JwtPayload, JwtType } from '../dto/jwt-payload';
import { AuthService } from '../services/auth.service';
import { UserRepository } from '../../user/repositories/user.repository';
import { UserService } from '../../user/user.service';
import { WorkspaceRepository } from '../../workspace/repositories/workspace.repository';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private authService: AuthService,
private userService: UserService,
private userRepository: UserRepository,
private workspaceRepository: WorkspaceRepository,
private readonly environmentService: EnvironmentService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: environmentService.getJwtSecret(),
passReqToCallback: true,
});
}
async validate(req, payload: JwtPayload) {
// CLOUD ENV
if (this.environmentService.isCloud()) {
if (req.raw.workspaceId && req.raw.workspaceId !== payload.workspaceId) {
throw new BadRequestException('Workspace does not match');
}
}
if (!payload.workspaceId || payload.type !== JwtType.ACCESS) {
throw new UnauthorizedException();
}
const workspace = await this.workspaceRepository.findById(
payload.workspaceId,
);
if (!workspace) {
throw new UnauthorizedException();
}
const user = await this.userRepository.findOne({
where: {
id: payload.sub,
workspaceId: payload.workspaceId,
},
});
if (!user) {
throw new UnauthorizedException();
}
return { user, workspace };
}
}