Re-implement authentication (#136)
* Remove hasura and hasura-auth * Implement authentication
This commit is contained in:
2
front/.env.example
Normal file
2
front/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
REACT_APP_API_URL=http://localhost:3000/graphql
|
||||
REACT_APP_AUTH_URL=http://localhost:3000/auth
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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('/');
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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
7
server/.env.example
Normal 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
12622
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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