feat: refactoring auth & add email password login (#318)

* feat: wip

* fix: issues

* feat: clean controllers and services

* fix: test

* Fix auth

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2023-06-17 13:42:02 +02:00
committed by GitHub
parent d13ceb98fa
commit 299ca293a8
215 changed files with 1668 additions and 680 deletions

View File

@ -1,9 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { PrismaService } from 'src/database/prisma.service';
import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { TokenService } from './token.service';
import { UserService } from 'src/core/user/user.service';
describe('AuthService', () => {
let service: AuthService;
@ -13,17 +11,13 @@ describe('AuthService', () => {
providers: [
AuthService,
{
provide: JwtService,
provide: TokenService,
useValue: {},
},
{
provide: ConfigService,
provide: UserService,
useValue: {},
},
{
provide: PrismaService,
useValue: prismaMock,
},
],
}).compile();

View File

@ -1,10 +1,16 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JwtPayload } from '../strategies/jwt.auth.strategy';
import { ConfigService } from '@nestjs/config';
import { v4 } from 'uuid';
import { RefreshToken, User } from '@prisma/client';
import { PrismaService } from 'src/database/prisma.service';
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ChallengeInput } from '../dto/challenge.input';
import { UserService } from 'src/core/user/user.service';
import { assert } from 'src/utils/assert';
import { RegisterInput } from '../dto/register.input';
import { PASSWORD_REGEX, compareHash, hashPassword } from '../auth.util';
import { VerifyEntity } from '../dto/verify.entity';
import { TokenService } from './token.service';
export type UserPayload = {
firstName: string;
@ -15,71 +21,79 @@ export type UserPayload = {
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private prismaService: PrismaService,
private readonly tokenService: TokenService,
private readonly userService: UserService,
) {}
async generateAccessToken(refreshToken: string): Promise<string | undefined> {
const refreshTokenObject = await this.prismaService.refreshToken.findFirst({
where: { refreshToken: refreshToken },
});
if (!refreshTokenObject) {
throw new HttpException(
{ reason: 'Invalid Refresh token' },
HttpStatus.FORBIDDEN,
);
}
const user = await this.prismaService.user.findUnique({
where: { id: refreshTokenObject.userId },
});
if (!user) {
throw new HttpException(
{ reason: 'Refresh token is not associated to a valid user' },
HttpStatus.FORBIDDEN,
);
}
const workspace = await this.prismaService.workspace.findFirst({
where: { workspaceMember: { some: { userId: user.id } } },
});
if (!workspace) {
throw new HttpException(
{ reason: 'Refresh token is not associated to a valid workspace' },
HttpStatus.FORBIDDEN,
);
}
const payload: JwtPayload = {
userId: user.id,
workspaceId: workspace.id,
};
return this.jwtService.sign(payload);
}
async registerRefreshToken(user: User): Promise<RefreshToken> {
const refreshToken = await this.prismaService.refreshToken.upsert({
async register(registerInput: RegisterInput) {
const existingUser = await this.userService.findUnique({
where: {
id: user.id,
email: registerInput.email,
},
create: {
id: v4(),
userId: user.id,
refreshToken: v4(),
},
update: {},
});
return refreshToken;
assert(!existingUser, 'This user already exist', NotFoundException);
assert(
PASSWORD_REGEX.test(registerInput.password),
'Password too weak',
BadRequestException,
);
const passwordHash = await hashPassword(registerInput.password);
const user = await this.userService.createUser({
data: {
displayName: registerInput.displayName,
email: registerInput.email,
passwordHash,
locale: 'en',
},
});
return user;
}
computeRedirectURI(refreshToken: string): string {
return `${this.configService.get<string>(
'FRONT_AUTH_CALLBACK_URL',
)}?refreshToken=${refreshToken}`;
async challenge(challengeInput: ChallengeInput) {
const user = await this.userService.findUnique({
where: {
email: challengeInput.email,
},
});
assert(user, "This user doens't exist", NotFoundException);
assert(user.passwordHash, 'Something wrong happened', ForbiddenException);
const isValid = await compareHash(
challengeInput.password,
user.passwordHash,
);
assert(isValid, 'Something wrong happened', ForbiddenException);
return user;
}
async verify(email: string): Promise<VerifyEntity> {
const data = await this.userService.findUnique({
where: {
email,
},
});
assert(data, "This user doens't exist", NotFoundException);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { passwordHash: _, ...user } = data;
const accessToken = await this.tokenService.generateAccessToken(user.id);
const refreshToken = await this.tokenService.generateRefreshToken(user.id);
return {
user,
tokens: {
accessToken,
refreshToken,
},
};
}
}

View File

@ -0,0 +1,36 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TokenService } from './token.service';
import { PrismaService } from 'src/database/prisma.service';
import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
describe('TokenService', () => {
let service: TokenService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TokenService,
{
provide: JwtService,
useValue: {},
},
{
provide: ConfigService,
useValue: {},
},
{
provide: PrismaService,
useValue: prismaMock,
},
],
}).compile();
service = module.get<TokenService>(TokenService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,211 @@
import {
ForbiddenException,
Injectable,
InternalServerErrorException,
NotFoundException,
UnauthorizedException,
UnprocessableEntityException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JwtPayload } from '../strategies/jwt.auth.strategy';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from 'src/database/prisma.service';
import { assert } from 'src/utils/assert';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { TokenEntity } from '../dto/token.entity';
import { TokenExpiredError } from 'jsonwebtoken';
@Injectable()
export class TokenService {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly prismaService: PrismaService,
) {}
async generateAccessToken(userId: string): Promise<TokenEntity> {
const expires = this.configService.get<string>('ACCESS_TOKEN_EXPIRES_IN');
assert(expires, '', InternalServerErrorException);
const expiresIn = ms(expires);
const expiresAt = addMilliseconds(new Date().getTime(), expiresIn);
const user = await this.prismaService.user.findUnique({
where: { id: userId },
include: {
workspaceMember: true,
},
});
if (!user) {
throw new NotFoundException('User is not found');
}
if (!user.workspaceMember) {
throw new ForbiddenException('User is not associated to a workspace');
}
const jwtPayload: JwtPayload = {
sub: user.id,
workspaceId: user.workspaceMember.workspaceId,
};
return {
token: this.jwtService.sign(jwtPayload),
expiresAt,
};
}
async generateRefreshToken(userId: string): Promise<TokenEntity> {
const secret = this.configService.get('REFRESH_TOKEN_SECRET');
const expires = this.configService.get<string>('REFRESH_TOKEN_EXPIRES_IN');
assert(expires, '', InternalServerErrorException);
const expiresIn = ms(expires);
const expiresAt = addMilliseconds(new Date().getTime(), expiresIn);
const refreshTokenPayload = {
userId,
expiresAt,
};
const jwtPayload = {
sub: userId,
};
const refreshToken = await this.prismaService.refreshToken.create({
data: refreshTokenPayload,
});
return {
token: this.jwtService.sign(jwtPayload, {
secret,
expiresIn,
// Jwtid will be used to link RefreshToken entity to this token
jwtid: refreshToken.id,
}),
expiresAt,
};
}
async generateLoginToken(email: string): Promise<TokenEntity> {
const secret = this.configService.get('LOGIN_TOKEN_SECRET');
const expires = this.configService.get<string>('LOGIN_TOKEN_EXPIRES_IN');
assert(expires, '', InternalServerErrorException);
const expiresIn = ms(expires);
const expiresAt = addMilliseconds(new Date().getTime(), expiresIn);
const jwtPayload = {
sub: email,
};
return {
token: this.jwtService.sign(jwtPayload, {
secret,
expiresIn,
}),
expiresAt,
};
}
async verifyLoginToken(loginToken: string): Promise<string> {
const loginTokenSecret = this.configService.get('LOGIN_TOKEN_SECRET');
const payload = await this.verifyJwt(loginToken, loginTokenSecret);
return payload.sub;
}
async verifyRefreshToken(refreshToken: string) {
const secret = this.configService.get('REFRESH_TOKEN_SECRET');
const jwtPayload = await this.verifyJwt(refreshToken, secret);
assert(
jwtPayload.jti && jwtPayload.sub,
'This refresh token is malformed',
UnprocessableEntityException,
);
const token = await this.prismaService.refreshToken.findUnique({
where: { id: jwtPayload.jti },
});
assert(token, "This refresh token doesn't exist", NotFoundException);
const user = await this.prismaService.user.findUnique({
where: {
id: jwtPayload.sub,
},
include: {
refreshTokens: true,
},
});
assert(user, 'User not found', NotFoundException);
if (token.isRevoked) {
// Revoke all user refresh tokens
await this.prismaService.refreshToken.updateMany({
where: {
id: {
in: user.refreshTokens.map(({ id }) => id),
},
},
data: {
isRevoked: true,
},
});
}
assert(
!token.isRevoked,
'Suspicious activity detected, this refresh token has been revoked. All tokens has been revoked.',
ForbiddenException,
);
return { user, token };
}
async generateTokensFromRefreshToken(token: string): Promise<{
accessToken: TokenEntity;
refreshToken: TokenEntity;
}> {
const {
user,
token: { id },
} = await this.verifyRefreshToken(token);
// Revoke old refresh token
await this.prismaService.refreshToken.update({
where: {
id,
},
data: {
isRevoked: true,
},
});
const accessToken = await this.generateAccessToken(user.id);
const refreshToken = await this.generateRefreshToken(user.id);
return {
accessToken,
refreshToken,
};
}
computeRedirectURI(loginToken: string): string {
return `${this.configService.get<string>(
'FRONT_AUTH_CALLBACK_URL',
)}?loginToken=${loginToken}`;
}
async verifyJwt(token: string, secret?: string) {
try {
return this.jwtService.verify(token, secret ? { secret } : undefined);
} catch (error) {
if (error instanceof TokenExpiredError) {
throw new UnauthorizedException('Token has expired.');
} else {
throw new UnprocessableEntityException();
}
}
}
}