chore: refacto NestJS in modules (#308)
* chore: wip refacto in modules * fix: rollback port * fix: jwt guard in wrong folder * chore: rename folder exception-filter in filters * fix: tests are running * fix: excessive stack depth comparing types * fix: auth issue * chore: move createUser in UserService * fix: test * fix: guards * fix: jwt guard don't handle falsy user
This commit is contained in:
31
server/src/core/auth/auth.module.ts
Normal file
31
server/src/core/auth/auth.module.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
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 { GoogleStrategy } from './strategies/google.auth.strategy';
|
||||
import { TokenController } from './token.controller';
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
import { UserModule } from '../user/user.module';
|
||||
|
||||
const jwtModule = JwtModule.registerAsync({
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRES_IN') + 's',
|
||||
},
|
||||
};
|
||||
},
|
||||
imports: [ConfigModule.forRoot({})],
|
||||
inject: [ConfigService],
|
||||
});
|
||||
|
||||
@Module({
|
||||
imports: [jwtModule, ConfigModule.forRoot({}), UserModule],
|
||||
controllers: [GoogleAuthController, TokenController],
|
||||
providers: [AuthService, JwtAuthStrategy, GoogleStrategy, PrismaService],
|
||||
exports: [jwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
46
server/src/core/auth/google.auth.controller.ts
Normal file
46
server/src/core/auth/google.auth.controller.ts
Normal file
@ -0,0 +1,46 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
36
server/src/core/auth/services/auth.service.spec.ts
Normal file
36
server/src/core/auth/services/auth.service.spec.ts
Normal file
@ -0,0 +1,36 @@
|
||||
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';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: prismaMock,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
85
server/src/core/auth/services/auth.service.ts
Normal file
85
server/src/core/auth/services/auth.service.ts
Normal file
@ -0,0 +1,85 @@
|
||||
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';
|
||||
|
||||
export type UserPayload = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private prismaService: PrismaService,
|
||||
) {}
|
||||
|
||||
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({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
create: {
|
||||
id: v4(),
|
||||
userId: user.id,
|
||||
refreshToken: v4(),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
computeRedirectURI(refreshToken: string): string {
|
||||
return `${this.configService.get<string>(
|
||||
'FRONT_AUTH_CALLBACK_URL',
|
||||
)}?refreshToken=${refreshToken}`;
|
||||
}
|
||||
}
|
||||
40
server/src/core/auth/strategies/google.auth.strategy.ts
Normal file
40
server/src/core/auth/strategies/google.auth.strategy.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
export type GoogleRequest = Request & {
|
||||
user: { firstName: string; lastName: string; email: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
clientID: configService.get<string>('AUTH_GOOGLE_CLIENT_ID'),
|
||||
clientSecret: configService.get<string>('AUTH_GOOGLE_CLIENT_SECRET'),
|
||||
callbackURL: configService.get<string>('AUTH_GOOGLE_CALLBACK_URL'),
|
||||
scope: ['email', 'profile'],
|
||||
});
|
||||
}
|
||||
|
||||
async validate(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
profile: any,
|
||||
done: VerifyCallback,
|
||||
): Promise<any> {
|
||||
const { name, emails, photos } = profile;
|
||||
const user = {
|
||||
email: emails[0].value,
|
||||
firstName: name.givenName,
|
||||
lastName: name.familyName,
|
||||
picture: photos[0].value,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
};
|
||||
done(null, user);
|
||||
}
|
||||
}
|
||||
43
server/src/core/auth/strategies/jwt.auth.strategy.ts
Normal file
43
server/src/core/auth/strategies/jwt.auth.strategy.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
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 PassportUser = { user: User; workspace: Workspace };
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prismaService: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<PassportUser> {
|
||||
const user = await this.prismaService.user.findUniqueOrThrow({
|
||||
where: { id: payload.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const workspace = await this.prismaService.workspace.findUniqueOrThrow({
|
||||
where: { id: payload.workspaceId },
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
}
|
||||
20
server/src/core/auth/token.controller.ts
Normal file
20
server/src/core/auth/token.controller.ts
Normal file
@ -0,0 +1,20 @@
|
||||
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