Re-implement authentication (#136)
* Remove hasura and hasura-auth * Implement authentication
This commit is contained in:
@ -3,9 +3,17 @@ import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { HealthController } from './health.controller';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ApiModule } from './api/api.module';
|
||||
@Module({
|
||||
imports: [TerminusModule, ApiModule],
|
||||
imports: [
|
||||
ConfigModule.forRoot({}),
|
||||
TerminusModule,
|
||||
AuthModule,
|
||||
ApiModule,
|
||||
],
|
||||
controllers: [AppController, HealthController],
|
||||
providers: [AppService],
|
||||
})
|
||||
|
||||
20
server/src/auth/auth.controller.ts
Normal file
20
server/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Controller, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthService } from './services/auth.service';
|
||||
|
||||
@Controller('auth/token')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post()
|
||||
generateAccessToken(@Req() req: Request, @Res() res: Response) {
|
||||
const refreshToken = req.body.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(400).send('Refresh token not found');
|
||||
}
|
||||
|
||||
return res.send(this.authService.generateAccessToken(refreshToken));
|
||||
}
|
||||
}
|
||||
38
server/src/auth/auth.module.ts
Normal file
38
server/src/auth/auth.module.ts
Normal file
@ -0,0 +1,38 @@
|
||||
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 { AuthController } from './auth.controller';
|
||||
import { UserRepository } from 'src/entities/user/user.repository';
|
||||
import { WorkspaceRepository } from 'src/entities/workspace/workspace.repository';
|
||||
import { RefreshTokenRepository } from 'src/entities/refresh-token/refresh-token.repository';
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.registerAsync({
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRES_IN'),
|
||||
},
|
||||
};
|
||||
},
|
||||
imports: [ConfigModule.forRoot({})],
|
||||
inject: [ConfigService],
|
||||
}), ConfigModule.forRoot({})],
|
||||
controllers: [GoogleAuthController, AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtAuthStrategy,
|
||||
GoogleStrategy,
|
||||
UserRepository,
|
||||
WorkspaceRepository,
|
||||
RefreshTokenRepository,
|
||||
PrismaService,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
27
server/src/auth/google.auth.controller.ts
Normal file
27
server/src/auth/google.auth.controller.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { Profile } from 'passport-google-oauth20';
|
||||
|
||||
@Controller('auth/google')
|
||||
export class GoogleAuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleAuth(@Req() req) {}
|
||||
|
||||
@Get('redirect')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleAuthRedirect(@Req() req: Request, @Res() res: Response) {
|
||||
const user = await this.authService.upsertUser(req.user as { firstName: string, lastName: string, email: string })
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).send('User not created');
|
||||
}
|
||||
const refreshToken = await this.authService.registerRefreshToken(user)
|
||||
return res.redirect(this.authService.computeRedirectURI(refreshToken.refreshToken));
|
||||
}
|
||||
|
||||
}
|
||||
5
server/src/auth/guards/jwt.auth.guard.ts
Normal file
5
server/src/auth/guards/jwt.auth.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
98
server/src/auth/services/auth.service.ts
Normal file
98
server/src/auth/services/auth.service.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { JwtPayload } from '../strategies/jwt.auth.strategy';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Profile } from 'passport-google-oauth20';
|
||||
import { UserRepository } from 'src/entities/user/user.repository';
|
||||
import { WorkspaceRepository } from 'src/entities/workspace/workspace.repository';
|
||||
import { RefreshTokenRepository } from 'src/entities/refresh-token/refresh-token.repository';
|
||||
import { v4 } from 'uuid';
|
||||
import { RefreshToken, User } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private userRepository: UserRepository,
|
||||
private workspaceRepository: WorkspaceRepository,
|
||||
private refreshTokenRepository: RefreshTokenRepository
|
||||
) {}
|
||||
|
||||
async upsertUser(rawUser: { firstName: string, lastName: string, email: string }) {
|
||||
if (!rawUser.email) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawUser.firstName || !rawUser.lastName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailDomain = rawUser.email.split('@')[1];
|
||||
|
||||
if (!emailDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findUnique({
|
||||
where: { domainName: emailDomain },
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.userRepository.upsertUser({
|
||||
data: {
|
||||
id: v4(),
|
||||
email: rawUser.email,
|
||||
displayName: rawUser.firstName + ' ' + rawUser.lastName,
|
||||
locale: 'en',
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
await this.userRepository.upsertWorkspaceMember({
|
||||
data: {
|
||||
id: v4(),
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
generateAccessToken(refreshToken: string) {
|
||||
const refreshTokenObject = this.refreshTokenRepository.findFirst({
|
||||
where: { id: refreshToken },
|
||||
});
|
||||
|
||||
if (!refreshTokenObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: JwtPayload = { username: 'Charles', sub: 1 };
|
||||
return {
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
refreshToken: refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async registerRefreshToken(user: User): Promise<RefreshToken> {
|
||||
const refreshToken = await this.refreshTokenRepository.upsertRefreshToken({
|
||||
data: {
|
||||
id: v4(),
|
||||
userId: user.id,
|
||||
refreshToken: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
computeRedirectURI(refreshToken: string): string {
|
||||
return `${this.configService.get<string>('FRONT_AUTH_CALLBACK_URL')}?refreshToken=${refreshToken}`;
|
||||
}
|
||||
}
|
||||
30
server/src/auth/strategies/google.auth.strategy.ts
Normal file
30
server/src/auth/strategies/google.auth.strategy.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@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_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,
|
||||
accessToken
|
||||
}
|
||||
done(null, user);
|
||||
}
|
||||
}
|
||||
30
server/src/auth/strategies/jwt.auth.strategy.ts
Normal file
30
server/src/auth/strategies/jwt.auth.strategy.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type JwtPayload = { sub: number; username: string };
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(configService: ConfigService) {
|
||||
const extractJwtFromCookie = (req) => {
|
||||
let token = null;
|
||||
|
||||
if (req && req.cookies) {
|
||||
token = req.cookies['jwt'];
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
super({
|
||||
jwtFromRequest: extractJwtFromCookie,
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
return { id: payload.sub, username: payload.username };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"refreshToken" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -29,6 +29,7 @@ model User {
|
||||
metadata Json?
|
||||
WorkspaceMember WorkspaceMember?
|
||||
companies Company[]
|
||||
RefreshTokens RefreshToken[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@ -95,3 +96,15 @@ model Person {
|
||||
|
||||
@@map("people")
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
refreshToken String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, RefreshToken } from '@prisma/client';
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTokenRepository {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async upsertRefreshToken(params: { data: Prisma.RefreshTokenUncheckedCreateInput}): Promise<RefreshToken> {
|
||||
const { data } = params;
|
||||
|
||||
return await this.prisma.refreshToken.upsert({
|
||||
where: {
|
||||
id: data.id,
|
||||
},
|
||||
create: {
|
||||
id: data.id,
|
||||
userId: data.userId,
|
||||
refreshToken: data.refreshToken,
|
||||
},
|
||||
update: {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findFirst(
|
||||
data: Prisma.RefreshTokenFindFirstArgs,
|
||||
): Promise<RefreshToken | null> {
|
||||
return await this.prisma.refreshToken.findFirst(data);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { User, Prisma } from '@prisma/client';
|
||||
import { User, Prisma, WorkspaceMember } from '@prisma/client';
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
@ -16,4 +16,39 @@ export class UserRepository {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prisma.user.findMany({ skip, take, cursor, where, orderBy });
|
||||
}
|
||||
|
||||
async upsertUser(params: { data: Prisma.UserCreateInput, workspaceId: string }): Promise<User> {
|
||||
const { data } = params;
|
||||
|
||||
return await this.prisma.user.upsert({
|
||||
where: {
|
||||
email: data.email
|
||||
},
|
||||
create: {
|
||||
id: data.id,
|
||||
displayName: data.displayName,
|
||||
email: data.email,
|
||||
locale: data.locale,
|
||||
},
|
||||
update: {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async upsertWorkspaceMember(params: { data: Prisma.WorkspaceMemberUncheckedCreateInput }): Promise<WorkspaceMember> {
|
||||
const { data } = params;
|
||||
|
||||
return await this.prisma.workspaceMember.upsert({
|
||||
where: {
|
||||
userId: data.userId
|
||||
},
|
||||
create: {
|
||||
id: data.id,
|
||||
userId: data.userId,
|
||||
workspaceId: data.workspaceId,
|
||||
},
|
||||
update: {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,4 +16,10 @@ export class WorkspaceRepository {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prisma.workspace.findMany({ skip, take, cursor, where, orderBy });
|
||||
}
|
||||
|
||||
async findUnique(
|
||||
data: Prisma.WorkspaceFindUniqueArgs,
|
||||
): Promise<Workspace | null> {
|
||||
return await this.prisma.workspace.findUnique(data);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user