DEV Community

Cover image for Django & DRF & Angular 101, partie 3.3
DUVAL Olivier
DUVAL Olivier

Posted on

Django & DRF & Angular 101, partie 3.3

Sommaire

Introduction

Nous verrons dans cette partie de la série Django & DRF & Angular :

  • l'authentification Angular avec Django en utilisant un token JWT avec les librairies drf-jwt côté django et @auth0/angular-jwt côté front
  • l'utilisation des guards en lien avec les rôles Django : Mme. Michue qui souhaite consulter la bibliothèque de livres et gest1 qui les gère ainsi que leurs auteurs
  • d'un module pour les gestionnaires qui ne sera chargé que pour les gestionnaires

Modèles

On va enrichir notre librairie, notamment sur les livres :

  • ajouter un résumé
  • modifier la contrainte de suppression on_delete sur l'auteur : en effet, actuellement, la suppression d'un autre supprime également...tous ses livres, le models.CASCADE provoque cette action. On va, à la place, mettre NULL à la ForeignKey lorsqu'un auteur est supprimé, on aura des livres orphelins mais au moins on les conserve !
class Book(TimeStampedModel):
    name = models.CharField(max_length=100, null=False, blank=False, db_index=True)
    summary = models.TextField(null=True, blank=True)
    nb_pages = models.IntegerField()

    author = models.ForeignKey(Author,
                               related_name='books',
                               null=True,
                               on_delete=models.SET_NULL)

    enabled = models.BooleanField(default=True, help_text='disponible ou non')

    def __str__(self):
        return '{} : {}'.format(self.name, self.author)
Enter fullscreen mode Exit fullscreen mode

le on_delete peut contenir les valeurs suivantes :

  • SET_NULL : remet à NULL la valeur (au lieu de la clé vers l'entité)
  • CASCADE : supprime l'entité courant si la clé étrangère est supprimée
  • PROTECT : ne fait rien, contrainte forte, des exceptions se léveront dans l'impossibilité telles ou telles entités
  • SET_DEFAULT : prend la valeur par défaut : SET : une autre entité pour remplacer

On effectue un python3 manage.py makemigrations --name 'book_upgrade_s
ummary_author' && python3 manage.py migrate
pour appliquer les modifications.

Utilisateurs et rôles : utilisateurs et groupes Django

Il s'agir de créer un groupe gestionnaire ayant les permissions suffisantes pour créer / modifier / supprimer / consulter un livre ou auteur, ce groupe aura un rôle "gestionnaire" qui lui permettra de manipuler les livres et auteurs.

Par soucis d'alléger le tutoriel, nous passons par l'Admin Django pour gérer toute ceci, il faudrait bien entendu un backoffice sous ...Angular pour ce type de gestion d'utilisateurs, groupes et permissions.

Sous l'admin (plateform/admin), dans les groupes, ajouter un groupe "gestionnaire" avec les droits sur les livres et auteurs

Groupes : Ajouter

Alt Text

Paramétrage du groupe gestionnaire avec les permissions "CRUD" sur Book et Author

Alt Text

puis un utilisateur gest1 sur ce nouveau groupe ayant les permissions

Alt Text

créer aussi un utilisateur michue pour notre dame qui souhaite juste consulter voire à termes commenter, sans aucun groupe et permissions.

Alt Text

Authentification

La méthode d'authentification qui s'est imposée depuis quelques années avec les SPA ou les applications mobiles est basée sur le token JWT.

On se basera sur cette méthode côté Angular pour l'obtenir et l'utiliser dans les appels API dont on a besoin.

JWT

L'objectif est d'utilisé l'API d'authentification pour obtenir un jeton JWT.

JWT étant un standard du W3C, composé de 3 parties : header.payload.signature

  • le header contient l'algorithme utilisé pour générer le token,
  • le payload les méta données (comme la date d'expiration exp) et données ajoutées (username)
  • la signature pour vérifier le token (qu'il n'a pas été altéré et que c'est bien...le bon token généré, pour la génération le SECRET_KEY du settings Django est utilisé par défaut).

L'extension Django / DRF utilisée est : drf-jwt ajouté dans les requirements.txt

Par exemple, à partir de http://plateform, étendre la partie api_token_auth puis saisir le login et mot de passe défini pour gest1

Alt Text

On aura comme résultat, sous forme de JSON, le token JWT ainsi que quelques informations supplémentaire sur l'utilisateur gest1

{
  "pk": 1587895563,
  "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6Imdlc3QxIiwiaWF0IjoxNTg3ODk1NTYzLCJleHAiOjE1ODc5MDk5NjMsInVzZXJfaWQiOjMsIm9yaWdfaWF0IjoxNTg3ODk1NTYzfQ.zBPgmxxbTKbg6_2cFEdOTg1G3JOk0QIhRlEam8EU7AA",
  "user": {
    "id": 3,
    "username": "gest1",
    "email": "john.do@domain.com",
    "is_superuser": false,
    "is_staff": false
  },
  "refresh_url": "/api-token-refresh/",
  "token_verify_url": "/api-token-verify/"
}
Enter fullscreen mode Exit fullscreen mode

Le flow d'une authentification se présente ainsi :

  • navigateur : login
  • POST sur api-token-auth du login/passe
  • serveur : vérification du login/passe, génération du JWT
  • navigateur : retour de la réponse et stockage du token si ok dans le localstorage
  • tous les nouveaux appels aux APIs se voient ajouter dans leurs entêtes Authorization: Bearer token pour potentiellement détecter l'utilisateur côté Django

Pour rappel, une application orientée API est stateless (sans état, revoir la partie consacrée sur REST), tout est porté par l'url / API. Ici, l'utilisateur authentifié est envoyé à chaque fois dans l'entête Authorization pour vérification côté serveur si on a besoin de l'utilisateur et/ou de ses permissions.

Egalement, le token pourra être stocké côté application sur le navigateur, grâce au localStorage, la librairie @auth0/angular-jwt (ci-après) permet de préciser comment peut être stocké via une configuration dans le module principal AppModule, la fonction tokenGetter()

export function tokenGetter(): string {
  return localStorage.getItem('token');
}

imports: [
    // ...
    JwtModule.forRoot({
      config: {
        tokenGetter,
        whitelistedDomains: ['localhost:4200', 'plateform'],
        blacklistedRoutes: [],
        skipWhenExpired: true,
      }
    }),
   // ...
]
Enter fullscreen mode Exit fullscreen mode

Un schéma simple du flux (pris sur cet excellent article)

Alt Text

Une fois connecte @auth0/angular-jwt ajoutera automatiquement à tous les appels API l'entête authorization (via un HttpInterceptor), token qu'interprétera DRF pour déterminer si l'utilisateur a les droits grâce aux permission_classes.

En mode anonyme

Alt Text

En mode connecté avec le compte gest1 gestionnaire

Alt Text

Angular authentification

On utilisera le module @auth0/angular-jwt côté front. Cette librairie fournit un service JwtHelperService qui nous aidera dans notre tâche, ainsi que du paramètrage pour retrouver le token JWT, et l'injection automatique de l'entête Authorization: Bearer token dans les APIs (merci l'HttpInterceptor d'Angular qu'utilise la librairie pour ça)

On va créer un service AuthentService qui effectuera le login et le logout, ainsi que quelques fonctions utilitaires (le visiteur était-il connecté ? obtenir le token, l'utilisateur, etc)

Service AuthentService

L'objectif de ce service d'authentifier l'utilisateur et si le retour est bon, de stocker le token ainsi que l'utilisateur user en localstorage, @auth0/angular-jwt pourra ainsi retrouver ce token grâce à la fonction tokenGetter vu précédemment.

userActivate$ nous servira à émettre ce nouveau connecté aux composants qui écoutent dessus et en ont besoin.


@Injectable({ providedIn: 'root' })
export class AuthService {
  private user: User;
  private url = `${environment.baseUrl}/api-token-auth/`;
  private userSource = new ReplaySubject<User>(1);
  userActivate$ = this.userSource.asObservable();

  constructor(private http: HttpClient,
              private jwtService: JwtHelperService) {
  }
  /**
   * Authentification /api-token-auth/
   * @param {UserAuthent} user : l'utilisateur a connecté
   * @return any dont token
   */ 
  logon(user: UserAuthent): Observable<any> {
    const headers = new HttpHeaders({
      'Content-Type': 'application/json', Accept: 'application/json'
    });
    return this.http
      .post(this.url, { username: user.username, password: user.password }, {headers})
      .pipe(map(dataJwt => this._authenticated(dataJwt)));
  }
  /**
   Déconnexion, on supprime tout ce qui est relatif au connecté et son 
   token 
  */
  logout() {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
  }
  isAuthenticated() {
    return !this.jwtService.isTokenExpired();
  }
  /**
   * Stockage du token et de quelques informations user
   * @param data
   * @private
   */
  private _authenticated(data: any): User {
    localStorage.setItem('token', data.token);
    localStorage.setItem('user', JSON.stringify(data.user));
    this.userSource.next(data.user as User);
    return data;
  }
}
Enter fullscreen mode Exit fullscreen mode

Component bannière de connexion

On va avoir besoin d'un component pour la bannière de connexion, ce dernier, au clic du bouton appellera logon() du service AuthentService que l'on a créé, au retour si tout est bon, redirige vers la page d'accueil. En cas de connexion infructueuse, un message est affiché, sinon on le redirige sur la page d'accueil en tant que connecté.

Alt Text

Alt Text

Alt Text

login() {
    this.loading = true;
    this.authService.logon(this.model)
      .pipe(finalize(() => this.loading = false))
      .subscribe(data => {
        if (data.token) {
          this.router.navigate(['/']);
          }
      },
      error => {
        this.loading = false;
        if (error instanceof HttpErrorResponse && error.status === 400) {
          const message =  error.error.non_field_errors[0];
          this.message = {
            message, label: '',
            color: 'red', icon: 'error'
          };
        } else {
          throw error;
        }
      }
    );
  }
Enter fullscreen mode Exit fullscreen mode

Guards

Les guards vont nous permettre de restreindre l'accès aux routes suivant des règles de gestion sur des rôles, à l'aide de 1 ou 2 interfaces : CanActivate (si le guard retourne true alors la route continue et charge le component) et potentiellement CanLoad (est-ce que les enfants peuvent être chargés ? typiquement pour un module à charger).

Nous allons définir un guard GestionnaireGuard pour les gestionnaires : si tu es gestionnaire alors les components de gestion (édition livres / auteurs) pourront être chargés.

Guard GestionnaireGuard : la fonction isGestionnaire() teste si le connecté a un rôle gestionnaire, elle renvoie un Observable trueou falseselon le résultat du test. J'utilise le UserGroupsService en écoutant le Subject connecte$, à chaque connexion et donc le nouveau connecté, je peux tester son groupe d'appartenance.

@Injectable({ providedIn: 'root'})
export class GestionnaireGuard implements CanLoad, CanActivate {
  constructor(private userGrpService: UserGroupsService) {
  }

  canLoad(route: Route): Observable<boolean> {
    return this.isGestionnaire();
  }
  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean>  {
    return this.isGestionnaire();
  }

  private isGestionnaire(): Observable<boolean> {
    return new Observable<boolean>(observer => {
      this.userGrpService.connecte$.subscribe(connecte => {
          observer.next(this._gestionnaire(connecte));
          observer.complete();
        }
      );
    });
  }
  private _gestionnaire(connecte: UserGroups) {
    return this.userGrpService.hasRole(connecte, roles.gestionnaire) ||
           this.userGrpService.hasRole(connecte, roles.admin);
  }
}

Enter fullscreen mode Exit fullscreen mode

Une fois le guard défini, il suffit de l'utiliser sur les routes qui nous intéressent, ici, celle de la gestion

app.routing.ts, route pour la gestion

// ...
const appRoutes: Routes = [
  {
    path: '',
    children: [
      {path: '', component: BooksListComponent},
      {path: 'gestion', loadChildren: () => import('./gestion/gestion.module').then(m => {
        return m.GestionModule; }), canLoad: [GestionnaireGuard], canActivate: [GestionnaireGuard]}
    ]
  },
  {path: '**', component: NotFoundComponent},
];
// ...
Enter fullscreen mode Exit fullscreen mode

et côté module gestion, gestion-routing.module.ts qui contient les routes liés au module gestion, celui-ci sera chargé uniquement si une des routes est appelée (lazy loading !)

// ...
const routes: Routes = [
  {
   path: '',
   canLoad: [GestionnaireGuard], canActivate: [GestionnaireGuard],
   children: [
    {path: 'book/edit', component: BookEditComponent},
    {path: 'author/edit', component: AuthorEditComponent}
   ]
 }
];
// ...
Enter fullscreen mode Exit fullscreen mode

Les guards permettent un contrôle assez fin des accès aux "vues" via les routes du connecté et de ses permissions.

Modules

Les modules sous Angular permettent de ne charger les composants associés (et donc les fichiers) que lorsque la route qui pointe dessus est appelée, dans le jargon, on appelle cela du lazy loading (chargement différé) : économie lors du chargement de l'application pour les utilisateurs lambdas, on ne charge que les "pages" uniquement lors de l'accès à la gestion par exemple. Cela peut servir aussi à partager des composants vers d'autres modules ou applications.

Dans notre application, on aura les modules suivants :

  • le module principal, déjà existant, chargé lors du lancement de l'application : AppModule (app.module.ts)
  • un module GestionModule (gestion.module.ts) qui servira uniquement aux gestionnaires lors de leur accès à la gestion des livres et auteurs.
  • un module transverse CommonLibraryModule qui contiendra potentiellement des composants partagés par les 2 modules précédents

Gestion

La gestion consiste pour un gestionnaire à :

  • éditer les livres : ajout ou mise à jour (édition / suppression)
  • éditer les auteurs : ajout ou mise à jour (édition / suppression)

seuls les gestionnaires peuvent avoir accès à ce type de gestion CRUD.

dans gestion, nous aurons un module gestion.module.ts pour la déclaration des composants utilisés dans la gestion de routes gestion-routing.module.ts du module Gestion.

Enfin, sur les routes de l'application principale, il suffit de déclarer les routes vers la gestion (édition livres et auteurs) avec les guards appropriés, nous aurons dans app.routing.ts, remarquez la partie gestion : dès qu'une route sur /gestion est appelée (/gestion/author/edit et /gestion/book/edit), le module gestion.module est chargée (ie : les sources et le chargement en mémoire)

const appRoutes: Routes = [
  {
    path: '',
    children: [
      {path: '', component: BooksListComponent},
      {path: 'authent', component: LoginComponent},
      {path: 'login', component: LoginComponent},
      {path: 'books', component: BooksListComponent},
      {path: 'authors', component: AuthorsListComponent},
      {path: 'gestion', loadChildren: () => import('./gestion/gestion.module').then(m => {
        return m.GestionModule; })}
    ]
  },
  {path: '**', component: NotFoundComponent},
];

Enter fullscreen mode Exit fullscreen mode

Consultation

Les composants de consultations (liste et affichage) font partis du module principal (app.module) car ils sont utilisés soit par les utilisateurs qui consultent mais aussi par les gestionnaire.

Nous aurons alors une déclaration simple pour les routes app.routing.ts, ici la route / et les routes books ou authors

const appRoutes: Routes = [
  {
    path: '',
    children: [
      {path: '', component: BooksListComponent},
      {path: 'authent', component: LoginComponent},
      {path: 'login', component: LoginComponent},
      {path: 'books', component: BooksListComponent},
      {path: 'authors', component: AuthorsListComponent},
      {path: 'gestion', loadChildren: () => import('./gestion/gestion.module').then(m => {
        return m.GestionModule; })}
    ]
  },
  {path: '**', component: NotFoundComponent},
];
Enter fullscreen mode Exit fullscreen mode

Conclusion et sources

Dans cet article, nous avons vu

  • le principe des rôles et permissions en Django
  • l'usage du JWT
  • la restrictions d'accès à certaines routes / vues via les guards

Retrouvez les sources sur la branche https://github.com/zorky/library/tree/django-drf-angular-3.3 de cet article.

Dans un prochaine article, nous irons beaucoup plus loin sur le composant "data-table", en proposant des filtres colonnes ou cellules dynamiques, ainsi que certaines améliorations.

Discussion (0)