Re-implement authentication (#136)

* Remove hasura and hasura-auth

* Implement authentication
This commit is contained in:
Charles Bochet
2023-05-25 11:51:15 +02:00
committed by GitHub
parent 5d06398d2e
commit 80f9cc8797
21 changed files with 1937 additions and 11092 deletions

2
front/.env.example Normal file
View File

@ -0,0 +1,2 @@
REACT_APP_API_URL=http://localhost:3000/graphql
REACT_APP_AUTH_URL=http://localhost:3000/auth

View File

@ -7,6 +7,7 @@ function Callback() {
const [isLoading, setIsLoading] = useState(true);
const refreshToken = searchParams.get('refreshToken');
console.log('refreshToken', refreshToken);
localStorage.setItem('refreshToken', refreshToken || '');
const navigate = useNavigate();

View File

@ -6,8 +6,7 @@ function Login() {
const navigate = useNavigate();
useEffect(() => {
if (!hasAccessToken()) {
window.location.href =
process.env.REACT_APP_AUTH_URL + '/signin/provider/google' || '';
window.location.href = process.env.REACT_APP_AUTH_URL + '/google' || '';
} else {
navigate('/');
}

View File

@ -1,20 +1 @@
HASURA_GRAPHQL_METADATA_DATABASE_URL=postgres://postgres:postgrespassword@postgres:5432/hasura
HASURA_GRAPHQL_PG_DATABASE_URL=postgres://postgres:postgrespassword@postgres:5432/default
HASURA_GRAPHQL_ADMIN_SECRET=secret
HASURA_GRAPHQL_JWT_SECRET='{"type":"HS256", "key": "jwt-very-long-hard-to-guess-secret"}'
HASURA_EVENT_HANDLER_URL=http://twenty-server:3000/hasura/events
HASURA_AUTH_SERVER_URL=http://localhost:4000
HASURA_AUTH_CLIENT_URL=http://localhost:3001/auth/callback
HASURA_AUTH_PROVIDER_GOOGLE_CLIENT_ID=REPLACE_ME
HASURA_AUTH_PROVIDER_GOOGLE_CLIENT_SECRET=REPLACE_ME
HASURA_AUTH_GRAPHQL_URL=http://twenty-hasura:8080/v1/graphql
FRONT_REACT_APP_API_URL=http://localhost:8080
FRONT_REACT_APP_AUTH_URL=http://localhost:4000
FRONT_HASURA_GRAPHQL_ENDPOINT=http://twenty-hasura:8080/v1/graphql
SERVER_HASURA_EVENT_HANDLER_SECRET_HEADER=secret
SERVER_DATABASE_URL=postgres://postgres:postgrespassword@postgres:5432/default
POSTGRES_PASSWORD=postgrespassword

View File

@ -7,9 +7,6 @@ services:
ports:
- "3001:3001"
- "6006:6006"
environment:
REACT_APP_API_URL: ${FRONT_REACT_APP_API_URL}
REACT_APP_AUTH_URL: ${FRONT_REACT_APP_AUTH_URL}
volumes:
- ../../front:/app/front
- twenty_node_modules_front:/app/front/node_modules
@ -24,8 +21,6 @@ services:
volumes:
- ../../server:/app/server
- twenty_node_modules_server:/app/server/node_modules
environment:
SERVER_DATABASE_URL: ${SERVER_DATABASE_URL}
depends_on:
- postgres
twenty-docs:

7
server/.env.example Normal file
View File

@ -0,0 +1,7 @@
AUTH_GOOGLE_CLIENT_ID=REPLACE_ME
AUTH_GOOGLE_SECRET=REPLACE_ME
AUTH_GOOGLE_CALLBACK_URL='http://localhost:3000/google/redirect'
JWT_SECRET=secret_jwt
JWT_EXPIRES_IN=300
SERVER_DATABASE_URL=postgres://postgres:postgrespassword@postgres:5432/default
FRONT_AUTH_CALLBACK_URL=http://localhost:3001/auth/callback

12622
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,10 +23,12 @@
"prisma:migrate": "npx prisma migrate deploy"
},
"dependencies": {
"@nestjs/apollo": "^11.0.5",
"@nestjs/apollo": "^10.0.5",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0",
"@nestjs/graphql": "^11.0.5",
"@nestjs/jwt": "^10.0.3",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/serve-static": "^3.0.0",
"@nestjs/terminus": "^9.2.2",
@ -34,6 +36,10 @@
"apollo-server-express": "^3.12.0",
"graphql": "^16.6.0",
"jest-mock-extended": "^3.0.4",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
@ -47,6 +53,7 @@
"@types/express": "^4.17.13",
"@types/jest": "28.1.8",
"@types/node": "^16.0.0",
"@types/passport-google-oauth20": "^2.0.11",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",

View File

@ -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],
})

View 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));
}
}

View 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 {}

View 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));
}
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View 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}`;
}
}

View 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);
}
}

View 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 };
}
}

View File

@ -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;

View File

@ -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")
}

View File

@ -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);
}
}

View File

@ -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: {
}
});
}
}

View File

@ -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);
}
}