Skip to content

Commit

Permalink
feat(2fa): implement two factor authentication using google authentic…
Browse files Browse the repository at this point in the history
…ator (#82)

* chore(twofa): add input on front to enable twofa, endpoint in back started

* feat(back): qr code generated in front and works with google authenticator app

* chore(twofa): verification code failed, try to know why

* fix(2fa): two fa works !

* enhancement(2fa): better lisibility on back by splitting 2fa in other controller
  • Loading branch information
Bima42 committed Mar 26, 2023
1 parent 6f7da9d commit 0ace6bd
Show file tree
Hide file tree
Showing 27 changed files with 534 additions and 5,258 deletions.
4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"pg": "^8.8.0",
"qrcode": "^1.5.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^4.4.0",
"rxjs": "^7.2.0",
"socket.io": "^4.6.1"
"socket.io": "^4.6.1",
"speakeasy": "^2.0.0"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "twoFAAuthenticated" BOOLEAN DEFAULT false;
29 changes: 15 additions & 14 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,21 @@ datasource db {
}

model User {
id Int @id @unique @default(autoincrement())
twoFA Boolean @default(false)
twoFASecret String?
fortyTwoId Int?
username String @unique @db.VarChar(255)
email String @unique
avatar String?
firstName String?
lastName String?
phone String?
status UserStatus @default(OFFLINE)
chats UserChat[]
games UserGame[]
messages ChatMessage[]
id Int @id @unique @default(autoincrement())
twoFA Boolean @default(false)
twoFAAuthenticated Boolean? @default(false)
twoFASecret String?
fortyTwoId Int?
username String @unique @db.VarChar(255)
email String @unique
avatar String?
firstName String?
lastName String?
phone String?
status UserStatus @default(OFFLINE)
chats UserChat[]
games UserGame[]
messages ChatMessage[]
friendRequest Friendship[] @relation(name: "requester")
friends Friendship[] @relation(name: "receiver")
Expand Down
48 changes: 48 additions & 0 deletions backend/src/auth/2fa/twofa.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Body, Controller, Post, Req, Res } from '@nestjs/common';
import { AuthService } from '../auth.service';
import { RequestWithUser } from '../../interfaces/request-with-user.interface';
import { Response } from 'express';
import { TwoFaService } from './twofa.service';

@Controller('2fa')
export class TwoFaController {
constructor(
private readonly authService: AuthService,
private readonly twoFaService: TwoFaService
) {}

@Post('verify')
async verify2fa(
@Req() req: RequestWithUser,
@Res() res: Response,
@Body() datas: { code: string }
){
const user = req.user;
console.log(user);
try {
await this.twoFaService.verifyTwoFactorAuthCode(user, datas.code);

if (req.cookies[process.env.JWT_COOKIE])
res.clearCookie(process.env.JWT_COOKIE);

this.authService.storeTokenInCookie(user, res);

res.status(200).send({ twoFAAuthenticated: true });
}
catch (e) {
res.status(500).send(e);
}
}

@Post('generate')
async generate2fa(@Req() req: RequestWithUser, @Res() res: Response) {
const user = req.user;
if (user.twoFA) {
const otpauthUrl = await this.twoFaService.generateTwoFactorAuthSecret(req.user);
const qrCodeImage = await this.twoFaService.generateQrCode(res, otpauthUrl);
res.status(200).json({ qrCodeImage: qrCodeImage });
} else {
res.status(400).send('2FA already enabled');
}
}
}
52 changes: 52 additions & 0 deletions backend/src/auth/2fa/twofa.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { UsersService } from '../../users/users.service';
import { User } from '@prisma/client';
import { Response } from 'express';
import * as speakeasy from 'speakeasy';
import * as QRCode from 'qrcode';

@Injectable()
export class TwoFaService {
constructor(
private readonly prismaService: PrismaService,
private readonly usersService: UsersService
) {}

async verifyTwoFactorAuthCode(user: User, code: string) {
const verified = speakeasy.totp.verify({
secret: user.twoFASecret,
encoding: 'base32',
token: code
});
if (!verified) {
throw new BadRequestException('Invalid code');
}
this.prismaService.user.update({
where: {
id: user.id
},
data: {
twoFAAuthenticated: true
},
});
}

async generateTwoFactorAuthSecret(user: User) {
const secret = speakeasy.generateSecret();
const otpauthUrl = speakeasy.otpauthURL({
secret: secret.base32,
encoding: 'base32',
label: 'Transcendence',
issuer: 'Transcendence',
});

await this.usersService.setTwoFaSecret(user.id, secret.base32);

return otpauthUrl;
}

async generateQrCode(res: Response, otpauthUrl: string) {
return QRCode.toDataURL(otpauthUrl);
}
}
87 changes: 24 additions & 63 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
Post,
Req,
Res,
UseGuards
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { Response, Request } from 'express';
import { Response } from 'express';
import { UserStatus } from '@prisma/client';
import { RequestWithUser } from '../interfaces/request-with-user.interface';

@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly usersService: UsersService
) {
}
) {}

/**
* Callback for 42 API authentication
Expand All @@ -27,39 +28,9 @@ export class AuthController {
* @param res
*/
@Get('42/callback')
async loginFortyTwoCallback(@Req() req: Request, @Res() res: Response) {
// Query to /oauth/authorize made by frontend with the custom url
// We get here after user has accepted to share his data with our app

// Get code from query params
const { code } = req.query;

async loginFortyTwoCallback(@Req() req: RequestWithUser, @Res() res: Response) {
try {
// POST request to /oauth/token to get access_token, must be on server side
const tokenResponse = await fetch('https://api.intra.42.fr/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'authorization_code',
client_id: process.env.FORTYTWO_API_UID,
client_secret: process.env.FORTYTWO_API_SECRET,
code: code,
redirect_uri: process.env.FORTYTWO_API_CALLBACK,
}),
});
const tokenData = await tokenResponse.json();

// We can use the token to make requests to the API
// GET request to /v2/me to get user data
const userDataResponse = await fetch('https://api.intra.42.fr/v2/me', {
method: 'GET',
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
});
const userData = await userDataResponse.json();
const userData = await this.authService.getUserDatasFrom42Api(req);

// Create or validate user with data from 42 API
const { id, email, first_name, last_name, phone, login, image } = userData;
Expand All @@ -73,49 +44,39 @@ export class AuthController {
avatar: image.versions.medium,
});

// Update user status
if (user) {
await this.usersService.updateStatus(user.id, UserStatus.ONLINE);
}

// Create token and set cookie
if (!req.cookies[process.env.JWT_COOKIE]) {
const token = this.authService._createToken(user);

if (!token) {
throw new ForbiddenException('Empty token');
}

res.cookie(process.env.JWT_COOKIE, token.access_token, {
maxAge: 1000 * 60 * 60 * 24, // 1 day
secure: true,
sameSite: 'none',
});
this.authService.storeTokenInCookie(user, res);
}

// Redirect to frontend redirectHandler
const redirectUrl = `${process.env.FRONTEND_URL}/redirectHandler`
let redirectUrl = '';
if (!user.twoFA || user.twoFAAuthenticated)
redirectUrl = `${process.env.FRONTEND_URL}/redirectHandler`
else
redirectUrl = `${process.env.FRONTEND_URL}/2fa`;
res.status(302).redirect(redirectUrl);
}

catch (e) {
console.error(e);
res.status(500).send('Authentication via 42 API failed');
res.status(500).send(e);
}
}

@Get('login')
async login(@Req() req: Request, @Res() res: Response) {
res.status(200).send(req.user);
async login(@Req() req: RequestWithUser, @Res() res: Response) {
const user = req.user;

await this.usersService.updateStatus(user.id, UserStatus.ONLINE);
res.status(200).send(user);
}

@Get('logout/:id')
async logout(
@Res({ passthrough: true }) res,
@Param() params: { id: number })
{
res.clearCookie(process.env.JWT_COOKIE);
@Res({ passthrough: true }) res,
@Param() params: { id: number }
) {
await this.usersService.updateStatus(params.id, UserStatus.OFFLINE);
await this.authService.logout(res, params.id);
await this.authService.logout(res);
res.status(302).redirect(`${process.env.FRONTEND_URL}/`);
}
}
10 changes: 7 additions & 3 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import { AuthController } from './auth.controller';
import { PrismaService } from '../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import {UsersMiddleware} from "../users/middlewares/users.middleware";
import { UsersMiddleware } from '../users/middlewares/users.middleware';
import { TwoFaController } from './2fa/twofa.controller';
import { TwoFaService } from './2fa/twofa.service';

@Module({
controllers: [
AuthController
AuthController,
TwoFaController
],
providers: [
AuthService,
TwoFaService,
PrismaService,
UsersService,
JwtService,
Expand All @@ -23,6 +27,6 @@ export class AuthModule implements NestModule {
consumer
.apply(UsersMiddleware)
.exclude('auth/42/callback')
.forRoutes(AuthController)
.forRoutes(AuthController, TwoFaController)
}
}
Loading

0 comments on commit 0ace6bd

Please sign in to comment.