Cours 18 - Retours, sécurité, seed
📬 Retours
En général, les actions des contrôleurs retournent le type Task<IActionResult> ou Task<IActionResult<T>>. Ça peut sembler vague.
Task<T>signifie simplement que la méthode est asynchrone. (Comme les fonctions qui retournentPromise<T>en TypeScript)IActionResultest une interface implémentée par de nombreuses classes, ce qui permet de retourner une panoplie de choses différentes avec une action.
🔮 Retours possibles
📦 Retourner des simples données JSON
Lorsqu'on souhaite simplement retourner des données, on a deux options :
- Simplement retourner la donnée. (Fonctionne si le type de retour de la méthode est
Task<IAction<TypeDeMaDonnée>>)
return maListe;
- Retourner la donnée avec
Ok(...)
return Ok(maListe);
Ces deux manières de procéder sont identiques en pratique. Elles correspondent toutes les deux à un code 200, qui signifie ✅ réussite de la requête. Vous êtes toutefois encouragés à utiliser Ok(...) puisque c'est un peu plus explicite.
🌌 Retourner... rien ?
Si on n'a rien de particulier à retourner (ex : une requête Put ou Delete qui a réussi), on peut utiliser NoContent().
return NoContent();
Cela retourne un code 204, qui veut dire ✅ réussite sans retour particulier.
❌ Retourner une erreur
Selon le type d'erreur, il existe plein de retours possibles. De plus, bien que ce soit optionnel, n'hésitez pas à glisser un message d'erreur en créant un objet JSON sur le pouce directement dans l'objet retourné.
- 🔍 Donnée inexistante, recherche non fructueuse, action inexistante, etc. (code 404) :
return NotFound(new { Message = "Aucune donnée n'a été trouvée."});
- 🕵️♂️ Utilisateur non authentifié ou aucun utilisateur trouvé (code 401) :
return Unauthorized(new { Message = "Connectez-vous d'abord."});
- 🔒 Utilisateur authentifié mais non autorisé à réaliser une opération (code 403) :
return Forbid(new { Message = "Hey hey tu n'as pas le droit de faire ça."});
- ❓ Requête inadéquate (code 400) Paramètres inadéquats, requête envoyée au mauvais moment, etc.
return BadRequest(mew { Message = "Cette opération n'est pas possible."});
- 🐞 Problème interne en lien avec le serveur. (code 500 à 511) Base de données ne répond pas, action non implémentée, stockage insuffisant, etc.
return StatusCode(StatusCodes.Status500InternalServerError, new { Message = "Une erreur est survenue. Veuillez réessayer le siècle prochain."});
Bien entendu, il existe beaucoup d'autres retours possibles, mais ceux-ci couvrent déjà l'essentiel pour ce cours.
🎁 Data-Transfer Objects
Nous avons abordé les DTOs dans le contexte où on souhaite envoyer de l'information depuis le client vers le serveur, mais il est également possible de faire l'inverse : créer un DTO pour envoyer des données vers le client.
Voici un exemple de classe qui n'est pas adaptée pour être envoyée au client telle quelle :
public class Comment{
public int Id { get; set; }
public string Text { get; set; } = null!;
[InverseProperty("Comments")]
public virtual User Author { get; set; } = null!;
[InverseProperty("Upvotes")]
public virtual List<User> Upvoters { get; set; } = new List<User>();
[InverseProperty("Downvotes")]
public virtual List<User> Downvoters { get; set; } = new List<User>();
}
Il y a trois propriétés inadéquates :
- On aimerait envoyer le pseudo de l'auteur (
string) plutôt queUseren entier. - On aimerait envoyer le nombre d'upvotes (
int) plutôt que la liste desUserqui ont upvoté. - On aimerait envoyer le nombre de downvotes (
int) plutôt que la liste desUserqui ont downvoté.
On produit donc un DTO qui contiendra les données adaptées :
public class CommentDisplayDTO{
public int Id { get; set; }
public string Text { get; set; } = null!;
public string Author { get; set; } = null!;
public int Upvotes { get; set; }
public int Downvotes { get; set; }
public CommentDisplayDTO(Comment comment){
Id = comment.Id;
Text = comment.Text;
Author = comment.User.UserName;
Upvotes = comment.Upvoters.Count;
Downvotes = comment.Downvotes.Count;
}
}
N'hésitez pas à ajouter un constructeur. Par exemple, dans ce cas, ça simplifiera (et ça centralisera) la conversion de Comment en CommentDisplayDTO.
Vous pouvez utiliser le suffixe DisplayDTO plutôt que DTO (par exemple) si vous souhaitez pouvoir différencier facilement vos deux types de DTOs. (Ceux pour envoyer des données vers le serveur VS ceux pour envoyer des données vers le client)
Une action qui retournerait une liste de CommentDisplayDTO pourrait procéder comme ceci :
[HttpGet]
public async Task<ActionResult<IEnumerable<CommentDisplayDTO>>> GetAllComments(){
IEnumerable<Comment> comments = await _context.Comment.ToListAsync();
// Conversion de la liste de Comment en liste de CommentDisplayDTO
return Ok(comments.Select(c => new CommentDisplayDTO(c)));
}
Côté Next.js, le modèle pourrait tout simplement ressembler à ceci :
export class Comment{
constructor(
public id : number,
public text : string,
public author : string,
public upvotes : number,
public downvotes : number
){}
}
🔒 Sécurité
Il y a quelques idées à garder à l'esprit lorsqu'on souhaite sécuriser notre application Web :
-
🖥 Inutile de tenter de sécuriser les composants clients dans l'application Next.js ! Tout ce code sera accessible à l'utilisateur de toute façon. La sécurité passe par le serveur.
-
🎁 Tout ce que le serveur retourne (sous forme de JSON) est accessible aux utilisateurs. (Que les données soient affichées par Next.js ou non)
-
📶 N'importe qui peut envoyer n'importe quelle requête avec n'importe quels paramètres ! Certains outils comme le logiciel Postman rendent cela très simple.
🩲 Oversharing
Lorsqu'on retourne des données à l'application cliente, il faut faire attention au oversharing. (Transmettre plus de données que nécessaire)
Par exemple, disons que mon serveur retourne une liste de Comment :
public class Comment{
public int Id { get; set; }
public string Text { get; set; } = null!;
public User Author { get; set; } = null!; // Danger ! Oversharing !
}
On a un problème : même si les données du User Author (numéro de téléphone, adresse courriel, hachage de mot de passe, etc.) ne sont pas toutes affichées avec le commentaire côté Next.js, elles ont quand même été envoyées au client et sont donc vulnérables.
La solution est plutôt simple dans cette situation : utiliser [JsonIgnore] :
public class Comment{
public int Id { get; set; }
public string Text { get; set; } = null!;
[JsonIgnore]
public User Author { get; set; } = null!;
}
Si jamais on souhaitait toutefois bel et bien envoyer certaines données de l'utilisateur pour les afficher (comme son UserName), il suffit de produire un DTO tel qu'abordé un peu plus haut dans ce cours.
👮♂️ Access control
Que ce soit lors d'un Get, Post, Put ou Delete, il faut parfois vérifier qui envoie la requête pour s'assurer que cet utilisateur soit autorisé à manipuler les données.
Rappelez-vous de cette précieuse ligne de code pour déterminer 🕵️♂️ qui envoie la requête (Utilisable dans un contrôleur) :
User? user = await _userManager.FindByIdAsync(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
Bien entendu, si aucun token n'est fourni, user sera null.
Pour pouvoir utiliser cette ligne de code, il faut avoir injecté UserManager dans son contrôleur. Cette classe fait office de « UserService » et existe déjà grâce à la librairie Identity.
Tous les exemples qui suivent seront abordés avec service puisqu'ils vous serviront durant les TPs.
📬 GET
Pour les actions de type GET, généralement utiliser une propriété de navigation ou encore effectuer un .Where(...) en se servant du pseudonyme de l'utilisateur qui envoie la requête permettra de s'assurer que seuls les utilisateurs autorisés ont accès à une donnée.
[HttpGet]
public async Task<ActionResult<IEnumerable<Comment>>> GetMyComments()
{
// Qui envoie la requête ?
User? user = await _userManager.FindByIdAsync(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
if (user == null) return Unauthorized(); // Non authentifié ou token invalide
return user.Comments; // Propriété de navigation utilisée
}
📦 POST
En général, il n'y a pas vraiment de risque en terme d'access control lorsqu'on essaye de créer une nouvelle donnée. Assurez-vous simplement de bien concrétiser le lien entre la donnée et l'utilisateur qui la crée au cas où on souhaiterait limiter l'accès aux données plus tard.
Bien entendu, pour empêcher un utilisateur non authentifié de créer une donnée, [Authorize] règle le problème.
⚙ Contrôleur :
[HttpPost]
public async Task<ActionResult<Comment>> PostComment(Comment comment)
{
User? user = await _userManager.FindByIdAsync(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
if (user == null) return Unauthorized(); // Non authentifié ou token invalide
// ✅ Le lien entre l'utilisateur est concrétisé par cette propriété de navigation !
comment.User = user; // ou encore -> user.Comments.Add(comment);
Comment? newComment = await _commentService.CreateComment(comment);
if(newComment == null) return StatusCode(StatusCodes.Status500InternalServerError,
new { Message = "Veuillez réessayer plus tard." }); // Problème avec la BD ?
return Ok(newComment);
}
🧰 Service :
public async Task<Comment?> CreateComment(Comment comment)
{
if (IsCommentSetEmpty()) return null;
_context.Comment.Add(comment);
await _context.SaveChangesAsync();
return comment;
}
🚮 DELETE
Le problème potentiel est plutôt évident : on ne veut pas permettre à n'importe qui de supprimer une donnée !
⚙ Contrôleur :
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteComment(int id)
{
// Utilisateur qui fait la requête
User? user = await _userManager.FindByIdAsync(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Commentaire à supprimer
Comment? comment = await _commentService.GetComment(id);
// Si le commentaire n'est pas trouvé
if (comment == null) return NotFound();
// 🛑 Si l'utilisateur n'est PAS propriétaire du commentaire
if (user == null || !user.Comments.Contains(comment)) return Unauthorized(new {Message = "Hey touche pas, c'est pas à toi !"});
// Supprimer le commentaire du DbContext
Comment? deletedComment = await _commentService.DeleteComment(comment);
if(deletedComment == null) return StatusCode(StatusCodes.Status500InternalServerError,
new { Message = "Veuillez réessayer plus tard." }); // Problème avec la BD ?
return Ok(new {Message = "Commentaire supprimé."});
}
🧰 Service :
public async Task<Comment?> DeleteComment(Comment comment)
{
if (IsCommentSetEmpty()) return null;
_context.Remove(comment);
await _context.SaveChangesAsync();
return comment;
}
📝 PUT
Avec un Put, il y a deux enjeux à surveiller :
1 - ✋ Empêcher certains utilisateurs de modifier des données qui ne leur appartiennent pas.
⚙ Contrôleur :
[HttpPut("{id}")]
public async Task<IActionResult> PutComment(int id, Comment comment)
{
User? user = await _userManager.FindByIdAsync(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
if (id != comment.Id) return BadRequest();
Comment? oldComment = await _commentService.GetComment(id);
if (oldComment == null) return NotFound();
// 🛑 Utilisateur pas propriétaire du commentaire ?
if(user == null || !user.Comments.Contains(oldComment)) return Unauthorized(new { Message = "Hey touche pas, c'est pas à toi !"});
Comment? newComment = await _commentService.UpdateComment(id, comment);
if(newComment == null) return StatusCode(StatusCodes.Status500InternalServerError,
new { Message = "Veuillez réessayer plus tard." }); // Problème avec la BD ?
return Ok(new {Message = "Commentaire modifié", Comment = newComment });
}
🧰 Service :
public async Task<Comment?> UpdateComment(int id, Comment comment)
{
if(IsCommentSetEmpty()) return null;
// Important car on a déjà sorti le commentaire de la BD plus tôt
_context.ChangeTracker.Clear();
// On remplace l'ancien commentaire avec l'id (int id) par le (Comment comment) reçu
_context.Entry(comment).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await _context.Comment.AnyAsync(x => x.Id == id)) return null; // Commentaire n'existe plus ?
else throw; // Erreur avec la BD
}
return comment;
}
2 - ✏ Empêcher les utilisateurs de modifier certaines propriétés jugées immuables.
En utilisant, par exemple, un DTO pour limiter les données qui sont reçues pour modifier la donnée, on peut empêcher les utilisateurs de modifier les propriétés qu'on juge immuables. (Ex : empêcher de changer l'auteur d'un Comment, le nombre d'upvotes, etc.)
⚙ Contrôleur :
[HttpPut]
public async Task<IActionResult> PutComment(EditCommentDTO editCommentDTO)
{
User? user = await _userManager.FindByIdAsync(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
Comment? oldComment = await _commentService.GetComment(editCommentDTO.Id);
if (oldComment == null) return NotFound();
// 🛑 Utilisateur pas propriétaire du commentaire ?
if(user == null || !user.Comments.Contains(oldComment)) return Unauthorized(new { Message = "Hey touche pas, c'est pas à toi !"});
Comment? newComment = await _commentService.UpdateComment(editCommentDTO.NewText, comment);
if(newComment == null) return StatusCode(StatusCodes.Status500InternalServerError,
new { Message = "Veuillez réessayer plus tard." }); // Problème avec la BD ?
return Ok(new {Message = "Commentaire modifié", Comment = newComment });
}
🧰 Service :
public async Task<Comment?> UpdateComment(string newText, Comment comment)
{
if(IsCommentSetEmpty()) return null;
// ⛔ On remplace SEULEMENT la propriété modifiable plutôt que de remplacer la donnée en entier.
comment.Text = newText;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await _context.Comment.AnyAsync(x => x.Id == id)) return null; // Commentaire n'existe plus ?
else throw; // Erreur avec la BD
}
return comment;
}
🌱 Seed
Préparer un seed permet de peupler la base de données avec un 🎲 jeu de données initial.
- Permet de créer des tests pour l'application.
- Accélère les tests manuels.
Notez qu'à chaque fois que le seed est modifié, une migration doit être générée et la base de données doit être mise à jour.
Commandes : dotnet ef migrations add nomDeLaMigration et dotnet ef database update
1 - 🌱 Redéfinir la méthode OnModelCreating dans le DbContext
public class serveur16Context : IdentityDbContext<User>
{
public serveur16Context(DbContextOptions<serveur16Context> options) : base(options){}
// Ici
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder); // Conservez cette ligne de code en tout temps
}
public DbSet<Comment> Comment { get; set; }
}
2 - 📦 Ajouter les données de test
👤 Modèle sans relation
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<Comment>().HasData(new Comment()
{
Id = 1,
Text = "Ce film a eu la pire note de l'histoire de IMDb",
IsReported = false
});
}
👥 Deux modèles sans relation
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<Comment>().HasData(
new Comment(){
Id = 1, Text = "Ce film a eu la pire note de l'histoire de IMDb", IsReported = false
},
new Comment(){
Id = 2, Text = "N'allez pas à ce McDonalds, mon fils a attrapé la covid dans la piscine à balles.", IsReported = false
});
}
🧑👧 Utilisateur
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Préparation de l'utilisateur
PasswordHasher<User> hasher = new PasswordHasher<User>(); // Si plusieurs utilisateurs, pas besoin de dupliquer cette ligne
User u1 = new User{
Id = "11111111-1111-1111-1111-111111111111", // Format GUID à respecter (8-4-4-4-12)
UserName = "Bob69",
Email = "bobibou@mail.com", // Optionnel
NormalizedUserName = "BOB69", // Important
NormalizedEmail = "BOBIBOU@MAIL.COM" // Important si on a mis un Email 2 lignes plus haut
};
// Hachage du mot de passe et ajout de l'utilisateur au seed
u1.PasswordHash = hasher.HashPassword(u1, "Salut1!");
builder.Entity<User>().HasData(u1);
}
Si on souhaitait ajouter un 2e utilisateur dans le seed, sont Id pourrait être 11111111-1111-1111-1111-111111111112.
Un Id d'utilisateur peut seulement contenir des symboles hexadécimaux, c'est-à-dire de 0 à 9 et de A à F.
🍒 Relation One-To-Many
Sachant que :
- Le modèle
Userpossède une liste deCommentnomméeComments. - Le modèle
Commentpossède unUsernomméAuthor.
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Le Modèle One-To- doit être créé AVANT pour que son id existe.
PasswordHasher<User> hasher = new PasswordHasher<User>(); // Si plusieurs utilisateurs, pas besoin de dupliquer cette ligne
User u1 = new User{
Id = "11111111-1111-1111-1111-111111111111", // Format GUID à respecter (8-4-4-4-12)
UserName = "Bob69",
Email = "bobibou@mail.com",
NormalizedUserName = "BOB69",
NormalizedEmail = "BOBIBOU@MAIL.COM"
};
// Hachage du mot de passe et ajout de l'utilisateur au seed
u1.PasswordHash = hasher.HashPassword(u1, "Salut1!");
builder.Entity<User>().HasData(u1);
// Le modèle -To-Many doit être créé APRÈS pour avoir accès à l'id du One-To- (créé plus haut)
builder.Entity<Comment>().HasData(new
{
Id = 1,
Text = "Ce film a eu la pire note de l'histoire de IMDb",
IsReported = false,
AuthorId = u1.Id // Remarquez u1.Id ! C'est ici que la relation est concrétisée
});
}
Remarquez la structure de la classe Comment :
public class Comment{
public int Id { get; set; }
public string Text { get; set; } = null!;
public bool IsReported { get; set; }
[JsonIgnore]
public User Author { get; set; } = null!;
}
Remarquez deux détails très importants :
-
Au lieu de
new Comment(){ ... }, on a simplement utilisénew{ ... }. Cela permet d'utiliser des noms de propriétés qui n'existent pas dans la classe telle queAuthorId. -
Dans la classe
Comment, la propriétéAuthorIdn'existe pas, alors pourquoi on a utilisé ce nom ? Car EntityFramework, en créant la tableComment, va retirer la propriété de navigation nomméAuthoret va ajouter une colonne qui combine le nomAuthoretId, ce qui donneAuthorId.
🍇 Relation Many-To-Many
Cette fois-ci :
- Le modèle
Commentpossède une liste deUsernomméeUpvoters. - Le modèle
Userpossède une liste deCommentnomméeUpvotedComments.
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Utilisateur
PasswordHasher<User> hasher = new PasswordHasher<User>(); // Si plusieurs utilisateurs, pas besoin de dupliquer cette ligne
User u1 = new User{
Id = "11111111-1111-1111-1111-111111111111", // Format GUID à respecter (8-4-4-4-12)
UserName = "Bob69",
Email = "bobibou@mail.com",
NormalizedUserName = "BOB69",
NormalizedEmail = "BOBIBOU@MAIL.COM"
};
u1.PasswordHash = hasher.HashPassword(u1, "Salut1!");
builder.Entity<User>().HasData(u1);
// Comment
builder.Entity<Comment>().HasData(
new {
Id = 1,
Text = "Ce film a eu la pire note de l'histoire de IMDb",
IsReported = false,
},
new {
Id = 2,
Text = "N'allez pas à ce McDonalds, mon fils a attrapé la covid dans la piscine à balles.",
IsReported = false,
});
// Table de liaison
builder.Entity<Comment>()
.HasMany(c => c.Upvoters)
.WithMany(u => u.UpvotedComments)
.UsingEntity(e => {
// Ajouter une ligne pour chaque liaison (Ici, Bob69 a upvoté les deux commentaires existants)
e.HasData(new { UpvotersId = u1.Id, UpvotedCommentsId = 1});
e.HasData(new { UpvotersId = u1.Id, UpvotedCommentsId = 2});
});
}
Encore une fois, dans la table de liaison, on remarque que les propriétés UpvotersId et UpvotedCommentsId, qui n'existent pas dans nos modèles, sont tout simplement la combinaison du nom d'une propriété de navigation existante ainsi que de Id.