Vous avez commencé à coder votre API, celle ci est déjà bien avancé et vous vous demander comment la sécuriser ?
La sécu avant tout, mais après le reste
Si on était pro (pro pro), on aurait commencé par la sécu. Mais on aime coder pour avoir du résultat. Et la sécu... bon, ça attendra... Oui mais non.
En fait cela n'a pas plus beaucoup d'incidence aujourd'hui avec Asp.net Core car peut importe l'ordre d'implémentation, le framework est tellement mature qu'implémenter la sécurité en phase finale ou primaire n'a que peut d'incidence... enfin en théorie.
Microsoft propose un "model" de sécurité pour la gestion des utilisateurs (authentification et autorisation) de votre API basé sur ce qu'il appelle "Identity" et souvent couplé à EntityFramework pour plus d'efficacité.
Oui mais, qu'en est t-il si notre base de donnée n'est pas SQL Server et que notre couche d'accès aux données n'utilise pas EntityFramework ?
Mise en situation
Cet article tente d'explorer le "User Management" à la sauce Microsoft, avec une couche à nous custom.
Partons du principe que nous avons déjà un modèle de données, une base de données Oracle par exemple, une couche DAL déjà toute prête (avec ou sans mapper type Dapper) et un service d'abstraction. Ce dernier va faire du CRUD (Create, Read, Update, Delete) et j'ajouterai List, un CRUDL quoi.
Dedans on un "équivalent" utilisateur (user) que l'on nommera Operator, qui possède un login (username) et un mot de passe (hashé).
Voyons voir comment coupler notre système avec les fonctionnalités de gestion utilisateur de base d'Asp.Net Core Identity.
Sous le capot
Afin d'implémenter ce modèle, on va devoir respecter ces interfaces :
C'est une couche d'abstraction qui va permettre de s'affranchir de comment la couche de données gère ses utilisateurs. Du moment que ça passe par une classe qui implémente l'interface IUserStore et IRoleStore, le reste on s'en fou. (enfin à vous de gérer l'implémentation quand même...)
UserManager et RoleManager comme leurs noms l'indique vont faire le boulot, on est coté couche métier avec une logique qui va vérifier par exemple la complexité du password (basé sur une configuration préétablit).
Controler de test
Créeons un controler "Access" basique avec 3 actions, toute en Get :
[ApiController] [Route("api/[controller]")] public class AccessController : ControllerBase { //[AllowAnonymous] [HttpGet] public ActionResult<string> Get() { return "Vous êtes autorisé (anonyme)."; } //[Authorize] [HttpGet("Authenticated")] public ActionResult<string> GetAuthenticated() { return "Vous êtes autorisé car vous êtes authentifié."; } //[Authorize(Roles = Role.Admin)] [HttpGet("RoleAdmin")] public ActionResult<string> GetAuthenticatedRoleAdmin() { return "Vous êtes autorisé car vous êtes authentifié et Admin."; } }
Noter que dans un premier temps les attributs [Authorize] sont commentés.
Maintenant fait un run de votre WebApi et requêter ces urls :
- https://localhost:44389/api/Access
- https://localhost:44389/api/Access/Authenticated
- https://localhost:44389/api/Access/RoleAdmin
Vous devriez voir afficher respectivement :
- Vous êtes autorisé (anonyme).
- Vous êtes autorisé car vous êtes authentifié.
- Vous êtes autorisé car vous êtes authentifié et Admin.
Bien entendu ceci n'est qu'un test temporaire pour valider que vos actions répondent bien. Maintenant vous pouvez décommenter les attributs [Authorize]
Maintenant, nous devrions avoir un code de retour 200 pour le 1er get, comme avant, car le fait d'ajouter [AllowAnonymous] ne change rien, il ignore tout les attributs [Authorize], mais il n'y en a pas ici. (il est utile si vous encapsuler tout le controler avec un [Authorize] et qu'une seule action peut être anonyme, donc sans authorisation particulière.)
Par contre pour l'action GetAuthenticated et GetAuthenticatedRoleAdmin nous devrions avoir un code de retour 401 : Unauthorized (les codes http), or si vous refaites une requête c'est le code 500 : internal server error qui s'affiche. Voici ce que l'API Asp.net Core nous retourne :
InvalidOperationException : No authentication Scheme was specified, and there was no Default Challenge Scheme found.
En effet, nous n'avons rien fait jusqu'ici pour dire à l'API comment nous voulions gérer nos authentifications.
Ajout d'un schéma d'authentification
Dans Startup.cs ajoutons ceci :
services.AddIdentity<Operator, Role>() .AddDefaultTokenProviders();
Ici vous définissez une identité de type Operator avec des rôles de type Role. (on verra ça plus tard).
Maintenant ré-exécuter les deux dernières requêtes, vous allez obtenir une 302 : Redirection temporaire, qui nous amène si on à taper l'url dans un navigateur vers une nouvelle page :
https://localhost:44389/Account/Login?ReturnUrl=%2Fapi%2FAccess%2FAuthenticated
Ce comportement est géré par Asp.net Core. L'attribut [Authorize] indique au framework que la ressource demandé est soumise à authentification. Donc au lieu de répondre un 401 unauthorized comme je l'indiquais, Asp.net Core redirige directement l'utilisateur vers un controler Account sur l'action Login. C'est utile si notre page login est sur le même serveur. Sinon faudra revoir ce comportement.
Notez le paramètre ReturnUrl. Il va nous servir à re-rediriger l'utilisateur sur la page initialement demandé si besoin après un sign-in correct.
Comment qu'on fait ?
Tout d'abord, je vous conseille d'écrire qq tests avant de tout casser. Ils vont juste nous permettre de valider que nos modifications n'affecterons pas le comportement actuel.
Pour utiliser le mode tout fait, dans le Startup.cs on ajoute le service suivant :
services.AddIdentityCore(options => { });
Vous allez avoir besoin de Microsoft.Extensions.Identity.Core. Mais cette étape nous l'avons faite précédemment et on veut customiser la mécanique.
C'est cette librairie qui va apporter les dépendances pour UserManager<T> et RoleManager<T>. UserManager fournit les APIs pour gérer l'utilisateur, comme le changement de mot de passe, la validation de mot de passe.
Voici la liste de des dépendances nécessaire dans le constructor de UserManager :
- IUserStore<TUser> store: The persistence store the manager will operate over
- IOptions<IdentityOptions> optionsAccessor: The accessor used to access the
IdentityOptions
- IPasswordHasher<TUser> passwordHasher: The password hashing implementation to use when saving passwords
- IEnumerable<IUserValidator<TUser>> userValidators: A collection of IUserValidator<TUser> to validate users against
- IEnumerable<IPasswordValidator<TUser>> passwordValidators: A collection of
IPasswordValidator<TUser>
to validate passwords against - ILookupNormalizer keyNormalizer: The
ILookupNormalizer
to use when generating index keys for users - IdentityErrorDescriber errors: The
IdentityErrorDescriber
used to provide error messages - IServiceProvider services: The
IServiceProvider
used to resolve services - ILogger<UserManager<TUser>> logger: The logger used to log messages, warnings and errors
// Identity Services services.AddTransient<IUserStore<Operator>, CustomUserStore>(); services.AddTransient<IRoleStore<Role>, CustomRoleStore>(); services.AddTransient<IEmailSender, EmailSenderService>(); services.AddTransient<ILogger, LoggerService>();
Ajoutons un Controler Account
private readonly UserManager<Operator> _userManager; private readonly SignInManager<Operator> _signInManager; private readonly IEmailSender _emailSender; private readonly ILogger _logger; public AccountController( UserManager<Operator> userManager, SignInManager<Operator> signInManager, IEmailSender emailSender, ILogger<AccountController> logger) { _userManager = userManager; _signInManager = signInManager; _emailSender = emailSender; _logger = logger; }
Le constructeur du controler prend en paramètre 4 classes qui viennent d'être instancier dans le Startup.cs. Ces dernières seront automatiquement injecté dans le controler à son instanciation.
A partir de la vous n'avez plus qu'a coder des actions pour ce controler, la exemple HttpPost Login(string username, string password).
Il faudra aussi implémenter les interfaces
CustomUserStore : IUserStore et CustomRoleStore : IRoleStore qui seront couplé à votre service d'accès aux données.
Personnellement, je ne suis pas allez plus loin, j'ai même fait marche arrière en se passant totalement du modèle/architecture Identity de Microsoft.
Ce modèle est bon et fait gagner du temps que si l'on pense l'utiliser dès le départ. Adapter son code est faisable, mais nécessite dans mon cas de trop modifier mon code. Et mes besoins sont assez minime ou déjà implémenté.
[Authorize]
Oui mais c'est bien beau tout ça, mais je ne suis toujours pas autorisé à accéder à mes ressources. On va dont ajouter un service d'authentification dans le startup.cs. Plusieurs options, passer par un cookie ou par un token.
Le token est plus polyvalent, on le préférera si l'API sera utilisé par autre chose qu'un site web, par exemple une appli mobile.
services.AddAuthentication(x => { x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(x => { x.RequireHttpsMetadata = false; x.SaveToken = true; x.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Const.Secret)), ValidateIssuer = false, ValidateAudience = false }; });
Const.Secret peut être mis dans votre fichier de config. C'est impératif de garder cette chaine de caractère SECRETE. D'où son nom. Le token étant généré/vérifié avec.
Essai sur le terrain
On va faire une requête avec un utilitaire type postman :
Youpi j'ai mon 401 : Unauthorized. On va noter aussi un header www-authenticate : Bearer. La réponse nous indique que la ressource n'est pas accessible car elle attend une authentification (et une autorisation mais ici on ne peut pas le savoir, et dans notre cas il n'y en a pas, cette ressource est ouverte à tous ceux qui sont authentifiés). Cette authentification est en attente d'un bearer token.
Et bien donnons le lui !
Il faut pour cela rajoutez dans le header une clé "Authorization" avec en valeur le mot clé "bearer" suivit d'un espace, suivit enfin de notre token.
Observez le code de retour 200 et le message "vous êtes autorisé car vous êtes authentifié." En effet, le token a été validé par Asp.Net Core et donc l'action décoré de l'attribut [Authorize] est accessible et répond normalement.
Obtention du Token
Comment obtenir un token ? Le plus logique est de l'obtenir lors du login. On peut aussi créer une action pour le renouveler automatiquement dans certaine situation.
Dans votre controler - action de login, après la phase de validation login/pass ok, on va générer le token :
// authentication successful so generate jwt token var tokenHandler = new JwtSecurityTokenHandler(); var key = Encoding.ASCII.GetBytes(Const.Secret); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, user.Id.ToString()), new Claim(ClaimTypes.Role, user.Role) }), Expires = DateTime.UtcNow.AddDays(7), SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) }; var token = tokenHandler.CreateToken(tokenDescriptor); user.Token = tokenHandler.WriteToken(token);
Le token est passé à l'objet user que vous retournerez à votre client pour qu'il puisse ensuite l'inclure dans toutes ses prochaines requêtes comme celle ci dessus. Noter le token contient aussi le role. Celui ci va nous servir plus tard.
Token JWT
Un token JWT peut être lu et déchiffrer. Le site suivant https://jwt.io/ va vous permettre de déchiffrer son contenu. Par exemple vous pouvez tester celui ci
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6IjU3Iiwicm9sZSI6IkFkbWluIiwibmJmIjoxNTYyNjg2NzAyLCJleHAiOjE1NjMyOTE1MDIsImlhdCI6MTU2MjY4NjcwMn0.c0ilTizJGLU2Ccwb2eQHRBbpIJk4NrIocbT2UZBVAxc
Notez que le role est Admin.
Les rôles
On va tester la dernière requête : api/Access/RoleAdmin qui comporte l'attribut [Authorize(Roles = Role.Admin)], donc seul les utilisateurs ayant ce role peuvent y accéder.
Bingo, mon utilisateur étant Admin a donc accès. On va modifier son role en BD, régénérer un token et tester :
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6IjU3Iiwicm9sZSI6IlVzZXIiLCJuYmYiOjE1NjI3NDc2MTUsImV4cCI6MTU2MzM1MjQxNSwiaWF0IjoxNTYyNzQ3NjE1fQ.0TTi5s1l98inhlnouy3zqUE8QNy7yiykO5xgu7zrQ4Y
Bingo, on a une 403 : Forbidden. La différence avec la 401 : Unauthorized c'est qu'ici mon utilisateur est authentifié mais ne dispose pas des droits pour accéder à cette ressource.
Le comportement est bon. Maintenant, voyons comment on pourrait le tester automatiquement
Tests
Aps.net Core permet de lancer un serveur de test "TestServer" et de faire des tests directement sur les controlers.
Pour cela on va créer un nouveau projet de test avec xUnit.
xUnit décore ses tests avec l'attribut [Fact]. J'utilise l'extension VS2017 CodeRush qui me permet de lancer les tests comme je le souhaites, un à un, en debug, tous, une collection...
J'ai jamais été un grand fan de test car jusque la on ne parlait que de test unitaire. La plus part du temps je ne vois pas d'intérêt à tester unitairement mon code (dans certain cas c'est quand même utile, surtout quand l'app gère du pognon). Mais tester que a + b vaut bien a + b... bon c'est une perte de temps.
Jusque la je préférais partir avec une collection de données lancé en mémoire que je fait passer dans une sorte de gros process. En fait j'ai toujours fait des tests, à ma manière, en mode console dev. Le soucis c'est que ce travail n'était fait qu'en dev... et que par moi. (bon j'ai beaucoup travaillé en solo...)
Mais une fois en prod, ce process de test était bien souvent oublié. Voir complètement dépassé par les évolutions de code et donc inutile car bien buggé.
Avec un projet spécial test, les tests suivent les évolutions de code. Donc on peut facilement constater de la régression. Mieux avec du déploiement continue on peut voir si le code passe tous les tests ou non, et donc interdire une mise en prod suite à un échec de test. On évite en gros les ennuis futurs.
Mais la ou ça devient vraiment intéressant, c'est de pouvoir faire des tests d'intégration et fonctionnel. Et dans notre web api, on va non seulement pouvoir tester les controlers mais aussi les authentifications et autorisations. On a donc aussi un test de sécurité embarqué dans notre solution.
Le TestServer va utiliser une interface spéciale qui va monter un environnement adéquat pour le ou les tests : IClassFixture
using System; using System.IO; using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using AuthServer.Abstraction; using AuthServer.Services; using AuthServerXUnitTest.mocks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.ViewComponents; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace AuthServerXUnitTest { public class TestFixture<TStartup> : IDisposable { public static string GetProjectPath(string projectRelativePath, Assembly startupAssembly) { var projectName = startupAssembly.GetName().Name; var applicationBasePath = AppContext.BaseDirectory; var directoryInfo = new DirectoryInfo(applicationBasePath); do { directoryInfo = directoryInfo.Parent; var projectDirectoryInfo = new DirectoryInfo(Path.Combine(directoryInfo.FullName, projectRelativePath)); if (projectDirectoryInfo.Exists) if (new FileInfo(Path.Combine(projectDirectoryInfo.FullName, projectName, $"{projectName}.csproj")).Exists) return Path.Combine(projectDirectoryInfo.FullName, projectName); } while (directoryInfo.Parent != null); throw new Exception($"Project root could not be located using the application root {applicationBasePath}."); } private TestServer Server; public IServiceProvider ServiceProvider { get; set; } public TestFixture() : this(Path.Combine("")) { } public HttpClient Client { get; } public void Dispose() { Client.Dispose(); Server.Dispose(); } protected virtual void InitializeServices(IServiceCollection services) { var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly; var manager = new ApplicationPartManager { ApplicationParts = { new AssemblyPart(startupAssembly) }, FeatureProviders = { new ControllerFeatureProvider(), new ViewComponentFeatureProvider() } }; services.AddSingleton(manager); } protected virtual void InitializeTestServices(IServiceCollection services) { TestUtilitiesServices testUtilitiesServices = new TestUtilitiesServices(); services.Add(new ServiceDescriptor(typeof(TestUtilitiesServices), testUtilitiesServices)); // singleton ServiceProvider = services.BuildServiceProvider(); } protected TestFixture(string relativeTargetProjectParentDir) { var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly; var contentRoot = GetProjectPath(relativeTargetProjectParentDir, startupAssembly); var configurationBuilder = new ConfigurationBuilder() .SetBasePath(contentRoot) .AddJsonFile("appsettings.json"); var webHostBuilder = new WebHostBuilder() .UseContentRoot(contentRoot) .ConfigureServices(InitializeServices) .ConfigureTestServices(InitializeTestServices) .UseConfiguration(configurationBuilder.Build()) .UseEnvironment("Development") .UseStartup(typeof(TStartup)); // Create instance of test server Server = new TestServer(webHostBuilder); // Add configuration for client Client = Server.CreateClient(); Client.BaseAddress = new Uri("https://localhost:44389"); Client.DefaultRequestHeaders.Accept.Clear(); Client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } } }
Ensuite on a plus qu'a indiqué à notre classe que l'on souhaite dériver de IClassFixture :
public class OperatorTests : IClassFixture<TestFixture>
Mes premiers tests été centrer sur le fonctionnel, créer un user, l'effacer, vérifier que le login est unique etc. Mais avec de vraies services, ceux utiliser dans mon projet principal. Dans mes tests j'utilise donc un ordre bien précis (création, modification, effacement... et pas l'inverse)
Puis j'ai rajouté une couche sécurité avec des attributs [Authorize] comme on vient de le voir. Tous mes tests on donc échoué.
J'ai du ajouter un hack dans mon code pour obtenir un token admin pour l'ajouter dans toute mes requêtes. Ce code est pas du tout secure, mais il n'est présent que sur le projet test. On l'injecte dans
InitializeTestServices ce qui me permettra de l'utiliser dans mes tests. Ca aurait pu être une classe static mais ça a l'intérêt de vous montrer qu'on peut injecter ce que l'on veut ici.
Pour tester les droits d'accès, j'ai créé une nouvelle TestFixture, cette fois ci j'ai émulé mon service utilisateur, je l'ai crée et injecté à la place de celui du projet principal. Cela me permet de me passer de base de donnée. De plus j'ajoute en mémoire des utilisateurs de tests (admin, poweruser, user...) et je peux ainsi tester les autorisations.
La puissance d'asp.net core est la pour moi. Pouvoir customiser comme on le veux un serveur, rajouter un service, ou le remplacer comme on le veut. Ça permet de tester très précisément son code.
Conclusion
Le but initial de cet article était d'ajouter une couche de sécurité pour l'authentification et l'autorisation d'un utilisateur pour l'accès au ressource d'une Web Api.
J'ai rédigé cet article au fil de l'eau, donc il a des fausses pistes que j'ai volontairement laissé.
L'ajout d'un token d'authentification est relativement simple quand on comprends comment cela fonctionne.
Par contre tester son code prends du temps, mais ce temps est largement rattrapé par la suite et de nombreuse erreur sont maintenant évitable en amont de la prod.
Sources :
https://docs.microsoft.com/fr-fr/aspnet/core/security/authorization/simple?view=aspnetcore-2.2
https://docs.microsoft.com/fr-fr/aspnet/core/security/authorization/simple?view=aspnetcore-2.2