Dzisiaj chciałbym w skrócie omówić jak zabezpieczyć nasze API, które napisane jest w .NET Core. Jeżeli chcemy mówić o bezpieczeństwie aplikacji, musimy poznać i zrozumieć dwa bardzo ważne pojęcia, które często są ze sobą mylone:
- Authentication, czyli uwierzytelnianie, to proces podczas którego sprawdzamy, czy dana osoba jest rzeczywiście tym za kogo się podaje.
- Authorization, czyli autoryzacja, polega na ustaleniu, czy podmiot ma dostęp do zasobu po który wysłał żądanie.
Bardziej na ten temat nie będę się rozpisywał, zachęcam do bardziej dogłębnej lektury, po przeczytaniu mojego wpisu oczywiście.
Zło konieczne
W wielkich systemach zabezpieczenia to sprawa oczywista: finanse, adresy etc. Nikomu nie trzeba tłumaczyć, że te dane pod żadnym pozorem nie mogą wpaść w niepowołane ręce. Jednak rozwijając małą aplikację można sobie pomyśleć: „Po co mi zabezpieczenia, to tylko mały projekt po godzinach, kto chciałby mi się tutaj włamywać?”. Takie myślenie może być zgubne. W sieci grasuje wiele niebezpieczeństw i nawet, jeżeli nie mamy żadnych danych poufnych, to po prostu możemy paść ofiarą zwykłej złośliwości i utracić cenne dla nas dane. Tak mi się skojarzyło z obrazkiem poniżej, za każdym razem jak będziesz odczuwał pokusę udostępniania API bez zabezpieczeń, przypomnij sobie słowa Melisandre.
Zdając sobie sprawę z zagrożeń o których wspomniałem, przystąpiłem do implementacji mojego API. Osobiście uważam, że jest to bardzo nudna część projektu, zło konieczne z którym trzeba się pogodzić. Cytując słowa Scotta Hanselmana: Shave the Yak, co oznacza mniej więcej tyle, że musimy się uporać z mniejszym problem, żebyśmy mogli przejść do konkretów.
Implementacja JWT Tokens
Na szczęście, do dyspozycji mamy wiele narzędzi które ułatwiają nam życie. Ja moje API zabezpieczam przy użyciu Tokenów JWT. Na razie przedstawię podejście minimalistyczne, w którym będziemy korzystali z indywidualnych kont użytkowników. W przyszłości chcę wykorzystać Social Login, czyli po prostu stare dobre Zaloguj się poprzez fejsbuka, gdzie oczywiście zamiast tego można wykorzystywać także Google, Twittera czy innego GitHub’a. No ale przejdźmy już do konkretów.
Na wstępie musimy dodać bazę danych, gdzie przechowywać będziemy użytkowników. Ja wykorzystam do tego Entity Framework. Dane konfiguracyjne przechowuje w plikach, które nie są wrzucone do repozytorium na GitHubie, a do ich odczytu dodałem własną klasę.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class AppDbContext: IdentityDbContext { private IAppConfiguration config; public AppDbContext(DbContextOptions options, IAppConfiguration config) : base(options) { this.config = config; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); optionsBuilder.UseSqlServer(config.DbConnectionString, ob = > ob.MigrationsHistoryTable("WordHuntMigrations")); } } |
Teraz definiujemy z jakich narzędzi będziemy korzystać. Dzieje się to w pliku Startup.cs
, w metodzie ConfigureServices
. Najpierw konfigurujemy wykorzystanie systemu identyfikacji Identity dostępnego w paczce Microsoft.AspNetCore.Identity
. W tym samym miejscu definiujemy, że do tego cely wykorzystamy właśnie Entity Framework.
1 2 3 4 5 6 7 |
public void ConfigureServices(IServiceCollection services) { // ... services.AddIdentity<IdentityUser, IdentityRole>() .AddEntityFrameworkStores<AppDbContext>(); //... } |
Następnie konfigurujemy to, w jaki sposób aplikacja będzie uwierzytelniała użytkowników. Oto fragment tej metody:
1 2 3 4 5 6 7 8 9 10 11 |
public void Configure(IApplicationBuilder app, IHostingEnvironment env, IAuthConfiguration authConfig) { //... app.UseCorsConfig(env) .UseIdentity() .UseJwtBearerTokenAuthentication(authConfig) .UseMvc(); //... } |
UseJwtBearerTokenAuthentication
to extension method, który dodałem w celu odchudzenia pliku Startup.cs
. To dopiero początek pracy nad projektem, a ten już rośnie w niebezpiecznym tempie. Dlatego staram się wydzielać część konfiguracji do innych plików. Implementacja wspomnianej metody:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public static IApplicationBuilder UseJwtBearerTokenAuthentication(this IApplicationBuilder app, IAuthConfiguration authConfig) { app.UseJwtBearerAuthentication(new JwtBearerOptions { AutomaticAuthenticate = true, AutomaticChallenge = true, TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authConfig.TokenKey)), ValidateLifetime = true, ValidateAudience = true, ValidAudience = authConfig.Audience, ValidateIssuer = true, ValidIssuer = authConfig.Issuer } }); return app; } |
Tutaj wprowadzamy nasz tajny klucz, który służy do szyfrowania naszego tokena. Jak poprzednio, przechowuje go w pliku konfiguracyjnym. Definiujemy także to kto wystawia, oraz kto będzie konsumentem naszego tokena. Wszystko to w celu jak najlepszego zabezpieczenia naszej aplikacji.
Ostatnim etapem zabezpieczenia naszej aplikacji jest dodanie klasy, która będzie generowała nam nasze tokeny. Nie będę w całości kopiował tego kodu (kod dostępny w serwisie GitHub tutaj), a tylko kilka kluczowych elementów.
1 2 3 4 5 6 7 8 9 10 11 |
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authConfig.TokenKey)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: authConfig.Issuer, audience: authConfig.Audience, claims: claims, expires: DateTime.UtcNow.AddMinutes(authConfig.TokenValidInMinutes), signingCredentials: credentials); var tokenString = new JwtSecurityTokenHandler().WriteToken(token); |
W sytuacji, gdy potwierdzimy tożsamość użytkownika, generujemy klucz, tak samo jak robiliśmy to podczas konfiguracji. Następnie przy pomocy algorytmu HmacSha256
będziemy szyfrować dane. Token zawiera podstawowe informacje o użytkowniku, a także zbiór jego uprawnień, Claims. Dokładniej o tym co zawiera taki token napiszę w innym poście, gdzie opowiem więcej o wspomnianych Claims.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
[ValidateModel] [HttpPost("token")] public async Task<IActionResult> CreateToken([FromBody] CredentialsModel model) { try { var result = await tokenGenerator.GenerateToken(model.Email, model.Password); if (result.ResultStatus == TokenGeneratorResultStatus.Success) return Ok(result.Token); else { logger.LogWarning($"Failed to generate the token: {result.ErrorMessage}"); return BadRequest("Failed to generate token"); } } catch (Exception ex) { logger.LogError($"Error occured while creating token {ex.Message}", ex); return StatusCode(500); } } |
Teraz wywołujemy tą klasę w kontrolerze i nasi użytkownicy mogą wysłać request o token.
Podsumowanie
Gdy przejdziemy przez kroki o których wspomniałem, otrzymamy gotowe rozwiązanie które pozwoli nam w pełni zabezpieczyć naszą aplikację. Nie wspomniałem tutaj o takich pojęciach jak np. CORS oraz SSL, ale myślę że będzie jeszcze ku temu okazja w kolejnych postach. Tymczasem zapraszam do komentowania oraz odwiedzenia mojego Twittera.