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:
@ -3,18 +3,22 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { GoogleAuthController } from './google.auth.controller';
|
||||
import { GoogleAuthController } from './controllers/google-auth.controller';
|
||||
import { GoogleStrategy } from './strategies/google.auth.strategy';
|
||||
import { TokenController } from './token.controller';
|
||||
import { TokenController } from './controllers/token.controller';
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { PasswordAuthController } from './controllers/password-auth.controller';
|
||||
|
||||
import { TokenService } from './services/token.service';
|
||||
|
||||
const jwtModule = JwtModule.registerAsync({
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
secret: configService.get<string>('ACCESS_TOKEN_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRES_IN') + 's',
|
||||
expiresIn: configService.get<string>('ACCESS_TOKEN_EXPIRES_IN'),
|
||||
},
|
||||
};
|
||||
},
|
||||
@ -24,8 +28,19 @@ const jwtModule = JwtModule.registerAsync({
|
||||
|
||||
@Module({
|
||||
imports: [jwtModule, ConfigModule.forRoot({}), UserModule],
|
||||
controllers: [GoogleAuthController, TokenController],
|
||||
providers: [AuthService, JwtAuthStrategy, GoogleStrategy, PrismaService],
|
||||
controllers: [
|
||||
GoogleAuthController,
|
||||
PasswordAuthController,
|
||||
TokenController,
|
||||
AuthController,
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
TokenService,
|
||||
JwtAuthStrategy,
|
||||
GoogleStrategy,
|
||||
PrismaService,
|
||||
],
|
||||
exports: [jwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
15
server/src/core/auth/auth.util.ts
Normal file
15
server/src/core/auth/auth.util.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
export const PASSWORD_REGEX = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/;
|
||||
|
||||
const saltRounds = 10;
|
||||
|
||||
export const hashPassword = async (password: string) => {
|
||||
const hash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
return hash;
|
||||
};
|
||||
|
||||
export const compareHash = async (password: string, passwordHash: string) => {
|
||||
return bcrypt.compare(password, passwordHash);
|
||||
};
|
||||
30
server/src/core/auth/controllers/auth.controller.spec.ts
Normal file
30
server/src/core/auth/controllers/auth.controller.spec.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { TokenService } from '../services/token.service';
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TokenService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
23
server/src/core/auth/controllers/auth.controller.ts
Normal file
23
server/src/core/auth/controllers/auth.controller.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { VerifyInput } from '../dto/verify.input';
|
||||
import { VerifyEntity } from '../dto/verify.entity';
|
||||
import { TokenService } from '../services/token.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly tokenService: TokenService,
|
||||
) {}
|
||||
|
||||
@Post('verify')
|
||||
async verify(@Body() verifyInput: VerifyInput): Promise<VerifyEntity> {
|
||||
const email = await this.tokenService.verifyLoginToken(
|
||||
verifyInput.loginToken,
|
||||
);
|
||||
const result = await this.authService.verify(email);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
54
server/src/core/auth/controllers/google-auth.controller.ts
Normal file
54
server/src/core/auth/controllers/google-auth.controller.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
InternalServerErrorException,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Response } from 'express';
|
||||
import { GoogleRequest } from '../strategies/google.auth.strategy';
|
||||
import { UserService } from '../../user/user.service';
|
||||
import { assertNotNull } from 'src/utils/assert';
|
||||
import { TokenService } from '../services/token.service';
|
||||
|
||||
@Controller('auth/google')
|
||||
export class GoogleAuthController {
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleAuth() {
|
||||
// As this method is protected by Google Auth guard, it will trigger Google SSO flow
|
||||
return;
|
||||
}
|
||||
|
||||
@Get('redirect')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
|
||||
const { firstName, lastName, email } = req.user;
|
||||
const displayName = [firstName, lastName].filter(assertNotNull).join(' ');
|
||||
|
||||
const user = await this.userService.createUser({
|
||||
data: {
|
||||
email,
|
||||
displayName,
|
||||
locale: 'en',
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new InternalServerErrorException(
|
||||
'User email domain does not match an existing workspace',
|
||||
);
|
||||
}
|
||||
|
||||
const loginToken = await this.tokenService.generateLoginToken(user.email);
|
||||
|
||||
return res.redirect(this.tokenService.computeRedirectURI(loginToken.token));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PasswordAuthController } from './password-auth.controller';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { TokenService } from '../services/token.service';
|
||||
|
||||
describe('PasswordAuthController', () => {
|
||||
let controller: PasswordAuthController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [PasswordAuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TokenService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<PasswordAuthController>(PasswordAuthController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
23
server/src/core/auth/controllers/password-auth.controller.ts
Normal file
23
server/src/core/auth/controllers/password-auth.controller.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ChallengeInput } from '../dto/challenge.input';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { LoginTokenEntity } from '../dto/login-token.entity';
|
||||
import { TokenService } from '../services/token.service';
|
||||
|
||||
@Controller('auth/password')
|
||||
export class PasswordAuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly tokenService: TokenService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async challenge(
|
||||
@Body() challengeInput: ChallengeInput,
|
||||
): Promise<LoginTokenEntity> {
|
||||
const user = await this.authService.challenge(challengeInput);
|
||||
const loginToken = await this.tokenService.generateLoginToken(user.email);
|
||||
|
||||
return { loginToken };
|
||||
}
|
||||
}
|
||||
21
server/src/core/auth/controllers/token.controller.ts
Normal file
21
server/src/core/auth/controllers/token.controller.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { BadRequestException, Body, Controller, Post } from '@nestjs/common';
|
||||
import { RefreshTokenInput } from '../dto/refresh-token.input';
|
||||
import { TokenService } from '../services/token.service';
|
||||
|
||||
@Controller('auth/token')
|
||||
export class TokenController {
|
||||
constructor(private tokenService: TokenService) {}
|
||||
|
||||
@Post()
|
||||
async generateAccessToken(@Body() body: RefreshTokenInput) {
|
||||
if (!body.refreshToken) {
|
||||
throw new BadRequestException('Refresh token is mendatory');
|
||||
}
|
||||
|
||||
const tokens = await this.tokenService.generateTokensFromRefreshToken(
|
||||
body.refreshToken,
|
||||
);
|
||||
|
||||
return { tokens: tokens };
|
||||
}
|
||||
}
|
||||
11
server/src/core/auth/dto/challenge.input.ts
Normal file
11
server/src/core/auth/dto/challenge.input.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class ChallengeInput {
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
5
server/src/core/auth/dto/login-token.entity.ts
Normal file
5
server/src/core/auth/dto/login-token.entity.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { TokenEntity } from './token.entity';
|
||||
|
||||
export class LoginTokenEntity {
|
||||
loginToken: TokenEntity;
|
||||
}
|
||||
7
server/src/core/auth/dto/refresh-token.input.ts
Normal file
7
server/src/core/auth/dto/refresh-token.input.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenInput {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
24
server/src/core/auth/dto/register.input.ts
Normal file
24
server/src/core/auth/dto/register.input.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { PASSWORD_REGEX } from '../auth.util';
|
||||
|
||||
export class RegisterInput {
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@Matches(PASSWORD_REGEX, { message: 'password too weak' })
|
||||
password: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
displayName: string;
|
||||
}
|
||||
4
server/src/core/auth/dto/token.entity.ts
Normal file
4
server/src/core/auth/dto/token.entity.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export class TokenEntity {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
11
server/src/core/auth/dto/verify.entity.ts
Normal file
11
server/src/core/auth/dto/verify.entity.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { TokenEntity } from './token.entity';
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
export class VerifyEntity {
|
||||
user: Omit<User, 'passwordHash'>;
|
||||
|
||||
tokens: {
|
||||
accessToken: TokenEntity;
|
||||
refreshToken: TokenEntity;
|
||||
};
|
||||
}
|
||||
7
server/src/core/auth/dto/verify.input.ts
Normal file
7
server/src/core/auth/dto/verify.input.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class VerifyInput {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
loginToken: string;
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Response } from 'express';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { GoogleRequest } from './strategies/google.auth.strategy';
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@Controller('auth/google')
|
||||
export class GoogleAuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleAuth() {
|
||||
// As this method is protected by Google Auth guard, it will trigger Google SSO flow
|
||||
return;
|
||||
}
|
||||
|
||||
@Get('redirect')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
|
||||
const user = await this.userService.createUser(req.user);
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
{ reason: 'User email domain does not match an existing workspace' },
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
const refreshToken = await this.authService.registerRefreshToken(user);
|
||||
return res.redirect(
|
||||
this.authService.computeRedirectURI(refreshToken.refreshToken),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
36
server/src/core/auth/services/token.service.spec.ts
Normal file
36
server/src/core/auth/services/token.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
211
server/src/core/auth/services/token.service.ts
Normal file
211
server/src/core/auth/services/token.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,11 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
export type GoogleRequest = Request & {
|
||||
user: { firstName: string; lastName: string; email: string };
|
||||
user: {
|
||||
firstName: string | undefined | null;
|
||||
lastName: string | undefined | null;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
||||
@ -5,7 +5,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
import { User, Workspace } from '@prisma/client';
|
||||
|
||||
export type JwtPayload = { userId: string; workspaceId: string };
|
||||
export type JwtPayload = { sub: string; workspaceId: string };
|
||||
export type PassportUser = { user: User; workspace: Workspace };
|
||||
|
||||
@Injectable()
|
||||
@ -17,13 +17,13 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||
secretOrKey: configService.get<string>('ACCESS_TOKEN_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<PassportUser> {
|
||||
const user = await this.prismaService.user.findUniqueOrThrow({
|
||||
where: { id: payload.userId },
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
import { Controller, Post, Req, Res } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthService } from './services/auth.service';
|
||||
|
||||
@Controller('auth/token')
|
||||
export class TokenController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post()
|
||||
async generateAccessToken(@Req() req: Request, @Res() res: Response) {
|
||||
const refreshToken = req.body.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(400).send('Refresh token not found');
|
||||
}
|
||||
|
||||
const token = await this.authService.generateAccessToken(refreshToken);
|
||||
return res.send({ accessToken: token });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user