Le but de cette partie est de voir quelques notions de sécurité.
Une fonctionnalité importante d'un serveur web est de pouvoir authentifier les utilisateurs et donc de ne pas laisser n'importe qui obtenir toutes les informations. Pour ce faire, nous allons mettre en place un système de mot de passe pour les utilisateurs, ainsi que la prise en charge d'un JWT : JSON Web Token. Après cela, l'accès aux APIs sera restreint uniquement aux utilisateurs authentifiés, et donc avec un JWT dans le header de leurs requêtes.
Pour cela, installez tout d'abord les modules requis :
npm install --save @nestjs/passport passport passport-local
npm install --save-dev @types/passport-local
Le module passport
fournit tout le nécessaire à la mise en place de "stratégie" d'authentification. Dans notre cas,
nous utiliserons la stratégie passport-local
, qui met en place un système d'authentification basé sur un identifiant et
un mot de passe.
Une fois les modules installés, ajoutez à votre entité user
, un nouvel attribut password
de type string et modifiez
éventuellement d'autres composants, e.g. le users.service
. Cet attribut password
sera également stocké en base. Pour le moment,
on ne s'intéresse pas à sécuriser les mots de passe de la base de données, on verra plus tard dans le tp comment hasher les mots de passe avant de les stocker.
Générez maintenant un nouveau module auth
, et ajoutez-y un nouveau service.
Implémentez dans ce service la méthode suivante :
public async validateUser(id: number, password: string) : Promise<User> {
/** To be implemented **/
}
qui vérifie que le mot de passe (password
) fourni en paramètre est bien le mot de passe de l'user
désigné par son id
passé en paramètre.
Si tel est le cas, alors la fonction retourne l'utilisateur, undefined
si non.
Une fois implémentée, nous allons ajouter notre stratégie au module auth
. Créez le fichier local.strategy.ts
dans le
module auth
:
import { Strategy } from "passport-local";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(
private authService: AuthService
) {
super();
}
async validate(username: string, password: string): Promise<any> {
const user: any = await this.authService.validateUser(+username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
Il est impératif de garder la signature de la méthode validate
car le module passport
va chercher après une telle
méthode, et si elle n'existe pas, la requête sera automatiquement rejetée. Faites attention à bien importer Strategy
de passport-local
(et non pas d'un autre package).
On utilisera donc l'id de l'utilisateur comme un username
. Il suffit de le "cast" en number
avec le symbole +
.
Mettez ensuite à jour votre auth.module.ts
:
import { Module } from '@nestjs/common';
+import { PassportModule } from '@nestjs/passport';
import { UsersModule } from 'src/users/users.module';
import { AuthService } from './auth.service';
+import { LocalStrategy } from './local.strategy';
@Module({
+ imports: [UsersModule, PassportModule],
- providers: [AuthService]
+ providers: [AuthService, LocalStrategy]
})
Un mécanisme super intéressant de NestJS est le système de Gardes (Guards). Ces gardes permettent de "protéger" les endpoints implémentés par les contrôleurs avec un predicat (une fonction booléenne). Dans le cadre de l'authentification, l'application des gardes est assez directe : on refuse l'accès aux utilisateurs non authentifiés ou avec des informations (le couple (username;password)) erronées.
On va donc mettre en place un Guard
pour protéger certains endpoints avec l'authentification.
Premièrement, générez un contrôleur dans le module auth
et ajoutez la méthode login()
suivante :
import { Controller, Post, Request, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Controller('auth')
export class AuthController {
@UseGuards(AuthGuard('local'))
@Post('login')
async login(@Request() request) {
return request.user;
}
}
Ici, on a plusieurs nouvelles fonctionnalités :
- Le décorateur
@UseGuards()
qui permet de spécifier une liste de gardes qui "protègera" l'endpoint géré par la méthode. Les gardes peuvent être également spécifiés au niveau du contrôleur, afin que tous les endpoints du contrôleur soient protégés par la même Garde (et ça évite de mettre desUseGuards()
à toutes les fonctions) ; - Le type
AuthGuard('local')
qui est une garde spéciale d'authentification, qui utilisera la stratégie 'local', qui a été implémentée juste avant. - Le décorateur / paramètre
@Request() request
qui modélise et porte toutes les informations de la requête que le client a fait.
Vous pouvez constater que le corps de cette méthode est pratiquement vide. En fait, cette méthode délègue toute la logique d'authentification à sa garde, et on évite alors la redondance de code.
Faites le nécessaire d'un point de vue base de données, et testez la nouvelle API avec les commandes suivantes :
# expected 201 with info of user with ID = 1
curl -X POST http://localhost:3000/auth/login -d 'username=1&password=valid_password' -v
# expecting 401 Unauthorized
curl -X POST http://localhost:3000/auth/login -d 'username=1&password=wrong_password' -v
Bien évidemment, faites attention aux valeurs de ces requêtes : la première a un password valide, et l'utilisateur sera validé, tandis que la deuxième non.
C'est bien beau l'authentification, mais comme nous avons un serveur REST, il est stateless et donc il faut fournir à chaque requête les informations d'authentification, ce qui peut être lourd. C'est pour cela que nous allons mettre en place un JSON Web Token qui permettra d'authentifier l'utilisateur plus facilement.
Pour commencer, on va installer le module passport-jwt
:
npm install --save @nestjs/jwt passport-jwt
Nous allons maintenant changer la logique d'authentification dans le auth.service
et le auth.controller
. En effet,
avec la garde AuthGard()
on sait que si le corps de la méthode qui gère l'endpoint auth/login
est exécuté, cela veut
dire que l'utilisateur a bien été authentifié, grâce à la stratégie "local" sous-jacente.
Ajoutez à votre service la méthode suivante :
async login(user: any) {
const payload = { username: user.id };
return {
access_token: this.jwtService.sign(payload),
};
}
ainsi que l'import et l'injection du JwtService
depuis le module @nestjs/jwt
dans le constructeur du AuthService.
Ici, on utilise la méthode sign()
du jwtService
pour générer un jeton à partir de certaines informations de
l'utilisateur, notamment ici son id
.
Créez maintenant un nouveau fichier auth/constants.ts
qui contiendra le code suivant :
export const jwtConstants = {
secret: 'secretKey',
};
Cette constante est le "sel" du jeton et doit restée secrète.
Nous allons maintenant ajouter le module Jwt
dans auth.module.ts
:
+import { JwtModule } from '@nestjs/jwt';
+import { jwtConstants } from './constants';
@Module({
imports: [
UsersModule,
PassportModule,
+ JwtModule.register({
+ secret: jwtConstants.secret,
+ signOptions: { expiresIn: '60s' },
+ })
],
providers: [AuthService, LocalStrategy],
controllers: [AuthController]
})
export class AuthModule { }
Ici, on importe le JwtModule
, et on le configure avec l'appel à la méthode register()
en donnant un objet configuration en paramètre.
La configuration est : le secret à utiliser, qui est la constante définie à l'étape précèdente ; les JWT
expirent au bout d'une minute.
Et on va maintenant mettre à jour la méthode login()
du auth.controller.ts
:
@UseGuards(AuthGuard('local'))
@Post('login')
async login(@Request() request) {
- return request.user;
+ return this.authService.login(request.user);
}
Vous pouvez maintenant tester que le backend crée bien un token en réutilisant la même commande curl
que précédement.
Vous devriez avoir en retour le token.
$ curl -X POST http://localhost:3000/auth/login -d 'username=1&password=valid_password'
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6MSwiaWF0IjoxNjE3NzM2NTQ4LCJleHAiOjE2MTc3MzY2MDh9.3GRHwA_Tpk_dJFddBooUZCf-2Al4EoajWziYjcMOS7E"}
De la même manière que pour la stratégie locale, nous allons implémenter une stratégie jwt
. Créez un nouveau fichier jwt.strategy.ts
avec le code suivant :
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
}
}
Et ajoutez cette nouvelle stratégie à votre module auth.module.ts
:
- providers: [AuthService, LocalStrategy],
+ providers: [AuthService, LocalStrategy, JwtStrategy],
C'est beau d'avoir un token mais il faut maintenant protéger les apis avec. De la même manière que pour la stratégie locale, i.e. le couple username;password
, on peut mettre en place une garde qui s'attend à voir un token valide dans le header de la requête.
Ajoutez simplement sur un contrôleur ou sur une API specifique :
@UseGuards(AuthGuard('jwt'))
Et tada ! Passport s'occupe de tout gérer pour vous. Par exemple, si vous ajoutez la garde sur l'api GET /users/
, vous pouvez la tester avec la ligne de commande suivante :
curl -X GET http://localhost:3000/users -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6MSwiaWF0IjoxNjE3NzM5MzkxLCJleHAiOjE2MTc3Mzk0NTF9.p8uuEpr16YOhoCPjwWNLLQyeKDCxvbixwDa0q60whYI"
Vous aurez évidemment un autre token après Bearer
.
Nous allons maintenant hasher le mot de passe des utilisateurs avant de le stocker en base. Premièrement, installez les modules suivants :
npm i bcrypt
npm i -D @types/bcrypt
Voilà le snippet de code qui permet de hasher le mot de passe :
import * as bcrypt from 'bcrypt';
const password: string = 'password';
const saltOrRounds = 10;
const hash = await bcrypt.hash(password, saltOrRounds);
Ce code est à utiliser lors de la création et l'enregistrement d'un utilisateur.
Pour comparer un mot de passe fourni par un utilisateur et celui stocké en base, le module bcrypt
fournit la méthode asynchrone compare
:
bcrypt.compare(password, hash);
On stockera en base le hash du mot de passe et non pas le mot de passe lui-même, dans le cas d'une fuite de votre base de données, vous protégez ainsi vos clients !
Mettez à jour votre code pour gérer les mots de passe hashés.
Si vous avez tout bien fait, le comportement n'a pas changé. Cependant, si vous affichez (attention à ne jamais le faire en dehors de ce tp) les mots de passe, vous obtiendrais un charabia (le hash du mot de passe en fait).
Helmet est une collection d'intergiciels qui ajoute des "Headers HTTP" pour protéger les applications web de failles bien connues. Bien simple d'utilisation, il en reste néamoins puissant et très utile.
Pour installer helmet
, utilisez la ligne de commande suivante :
npm i --save helmet
Ensuite, il suffit de modifier son main.ts
, ou le fichier où l'application est bootstrappée avec :
+ import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
+ app.use(helmet());
En ajoutant l'option -v
à une commande curl, vous pourrez observer que helmet
ajoute tout un tas de headers aux réponses du serveur :
< HTTP/1.1 200 OK
< Content-Security-Policy: default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests
< X-DNS-Prefetch-Control: off
< Expect-CT: max-age=0
< X-Frame-Options: SAMEORIGIN
< Strict-Transport-Security: max-age=15552000; includeSubDomains
< X-Download-Options: noopen
< X-Content-Type-Options: nosniff
< X-Permitted-Cross-Domain-Policies: none
< Referrer-Policy: no-referrer
< X-XSS-Protection: 0
< Content-Type: application/json; charset=utf-8
< Content-Length: 348
< ETag: W/"15c-/dAWKidyV+/UGo0ucAQaRfi+Md0"
< Date: Tue, 06 Apr 2021 20:34:05 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
Sans helmet
, vos réponses fuiteront le fait que votre application a été faite avec ExpressJS, un framework nodejs sur lequel est bati NestJS
pour construire des applications web. Dans le retour d'une commande curl
sans helmet
, vous devriez trouver quelques chose comme :
X-Powered-By: Express
Qu'est-ce qu'on peut faire avec ça ? C'est une bonne question et c'est en dehors de la portée du projet. Cependant, ce blog explique comment exploiter cette connaissance pour s'approprier des droits (par exemple d'adminstration dans votre application) ou modifier des ressources qui ne sont pas accessibles normalement (Je rappelle ici, que ce blog est à but éducatif, et vous ne devez en aucun cas reproduire ou tenter ces attaques sur de véritables applications, sous peine de sanction pénale. L'UR1 décline toute responsabilité).