Identity Framework Core w ASP.NET — rejestracja, logowanie i autoryzacja od zera

Jak w ASP.NET Core MVC dodać pełny mechanizm rejestracji, logowania i zabezpieczyć kontroler atrybutem [Authorize]. Krok po kroku, bez ściemy.

JAJarosław Armatys 8 min czytania
Wersja wideo tego wpisu — obejrzyj na YouTube

To ósmy odcinek z dziesięciu w kursie podstaw ASP.NET Core MVC. Jeżeli prześledziłeś poprzednie lekcje, masz już działający magazyn produktów, bazę danych podpiętą przez EF Core i formularze CRUD-owe. Teraz pora na rzecz, bez której żadna serio aplikacja nie ruszy w świat — uwierzytelnianie użytkowników. Zamiast pisać to od zera (i robić po drodze pięć dziur bezpieczeństwa), użyjemy Identity Framework Core i w mniej niż dwadzieścia minut dorzucimy rejestrację, logowanie, wylogowanie oraz blokadę wybranych kontrolerów dla niezalogowanych.

Po co w ogóle Identity

Identity to gotowa warstwa uwierzytelniania od Microsoftu, która siedzi na EF Core i odwala brudną robotę: hashuje hasła, trzyma tabele AspNetUsers, AspNetRoles, AspNetUserRoles i parę innych, obsługuje role, claimy, potwierdzenia mailowe, blokady konta po N nieudanych próbach. Ty piszesz dwie metody w kontrolerze i formularz, reszta dzieje się pod maską.

Pisanie autentykacji ręcznie, bez solidnego powodu, to proszenie się o kłopoty. Identity nie jest idealny, ale zamyka kilkanaście typowych ataków, o których większość juniorów nawet nie słyszała.

W kursowej aplikacji zakładamy prosty scenariusz — użytkownik rejestruje się przez login, e-mail i hasło, loguje się tym samym loginem, a nasz magazyn produktów ma być dostępny tylko dla zalogowanych. Tyle.

Instalacja paczek i pierwszy migration

Zaczynamy od NuGeta. W Package Manager dokładamy dwie paczki — Microsoft.AspNetCore.Identity.EntityFrameworkCore oraz Microsoft.AspNetCore.Identity.UI. Bardzo istotne: wersje muszą grać z wersją Twojej aplikacji. W kursie mamy .NET 5, więc paczki też idą w wersji 5.x. Jak pomieszasz wersje, wszystko wybuchnie dopiero na runtime, a komunikat błędu nie ułatwi Ci życia.

Dalej idziemy do Startup.cs, metody ConfigureServices, i konfigurujemy Identity. Trzeba tylko pamiętać, że kontekst bazy danych (AppDbContext) musi teraz dziedziczyć z IdentityDbContext, a nie ze zwykłego DbContext — inaczej EF nie wygeneruje tabel Identity.

// Startup.ConfigureServices
services.AddDefaultIdentity<IdentityUser>(options =>
{
    options.SignIn.RequireConfirmedAccount = true;
    options.Password.RequiredLength = 8;
})
.AddEntityFrameworkStores<AppDbContext>();

W kursie odpuszczam restrykcyjne hasła (ustawiam RequireDigit = false, RequireUppercase = false itd.), bo chcę szybko przejść do rzeczy. Na produkcji zostaw domyślne albo dociśnij mocniej.

Po zmianie dziedziczenia AppDbContext robimy migrację:

Add-Migration AddIdentity
Update-Database

Efekt — w bazie pojawia się siedem nowych tabel (AspNetUsers, AspNetUserClaims, AspNetUserLogins, AspNetUserTokens, AspNetRoles, AspNetRoleClaims, AspNetUserRoles). Nas interesuje głównie pierwsza — tam wpadną konta po rejestracji.

Kontroler kont — Register, Login, Logout

Zamiast scaffoldować gotowe strony Identity (co też jest opcją), robimy własny AccountController z trzema akcjami: Register, Login, Logout. Każda ma wariant GET (pokaż formularz) i — oprócz Logout — wariant POST (odbierz dane).

Potrzebujemy dwóch ViewModeli w folderze Models:

public class RegisterViewModel
{
    [Required]
    public string Username { get; set; }

    [Required]
    [EmailAddress]
    public string Email { get; set; }

    [Required]
    [DataType(DataType.Password)]
    public string Password { get; set; }
}

public class LoginViewModel
{
    [Required]
    public string Username { get; set; }

    [Required]
    [DataType(DataType.Password)]
    public string Password { get; set; }
}

Widoki Register.cshtml i Login.cshtml to proste formularze silnie typowane przez @model, z Html.TextBoxFor dla loginu i maila oraz Html.PasswordFor dla hasła — dzięki temu pole hasła jest zamaskowane kropkami bez wymyślania koła na nowo.

W kontrolerze wstrzykujemy dwa serwisy przez konstruktor — UserManager<IdentityUser> do zakładania konta i SignInManager<IdentityUser> do logowania/wylogowania.

public class AccountController : Controller
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly SignInManager<IdentityUser> _signInManager;

    public AccountController(
        UserManager<IdentityUser> userManager,
        SignInManager<IdentityUser> signInManager)
    {
        _userManager = userManager;
        _signInManager = signInManager;
    }

    [HttpPost]
    public async Task<IActionResult> Register(RegisterViewModel model)
    {
        if (!ModelState.IsValid) return View(model);

        var user = new IdentityUser
        {
            UserName = model.Username,
            Email = model.Email
        };

        var result = await _userManager.CreateAsync(user, model.Password);

        if (result.Succeeded)
            return RedirectToAction("Index", "Home");

        return View(model);
    }
}

Zwróć uwagę na dwie rzeczy. Po pierwsze — hasło przekazujemy osobnym parametrem do CreateAsync, żeby Identity mogło je zahashować, zanim cokolwiek wyląduje w bazie. Nigdy nie zapisuj hasła na IdentityUser wprost. Po drugie — metoda jest async i zwraca Task<IActionResult>. Jeżeli async/await brzmi Ci obco, potraktuj to na razie jak magię, którą rozbierzemy na części w osobnym odcinku.

Logowanie jest jeszcze prostsze:

[HttpPost]
public async Task<IActionResult> Login(LoginViewModel model)
{
    if (!ModelState.IsValid) return View(model);

    var result = await _signInManager.PasswordSignInAsync(
        model.Username,
        model.Password,
        isPersistent: false,
        lockoutOnFailure: false);

    if (result.Succeeded)
        return RedirectToAction("Index", "Home");

    return View(model);
}

Parametr isPersistent decyduje, czy ciasteczko sesji ma przeżyć zamknięcie przeglądarki, a lockoutOnFailure — czy po paru nieudanych próbach Identity ma zablokować konto. Progi blokady konfiguruje się w Startup.

Wylogowanie to dosłownie jedna linijka — await _signInManager.SignOutAsync() i RedirectToAction("Index", "Home").

Middleware i ukrywanie przycisków w layoucie

W Startup.Configure, między UseRouting a UseAuthorization, musimy dodać app.UseAuthentication(). Bez tej linii całe logowanie przejdzie bez błędów, ale aplikacja nigdy nie pozna zalogowanego użytkownika. Kolejność jest kluczowa — najpierw UseAuthentication (kim jesteś?), potem UseAuthorization (czy wolno Ci tu wejść?).

W layoucie (_Layout.cshtml) podmieniamy nawigację, żeby w zależności od stanu użytkownika pokazywać różne linki:

@if (User.Identity.IsAuthenticated)
{
    <a asp-controller="Account" asp-action="Logout">Wyloguj</a>
}
else
{
    <a asp-controller="Account" asp-action="Login">Zaloguj</a>
    <a asp-controller="Account" asp-action="Register">Rejestracja</a>
}

User.Identity.IsAuthenticated to gotowy property na ControllerBase i dostępny jest też w widokach — zero ceregieli.

Blokada kontrolera atrybutem [Authorize]

No i wisienka na torcie. Mamy już zalogowanego użytkownika, ale nasz WarehouseController nadal jest otwarty dla każdego, kto zna URL. Zabezpieczamy jedną linijką:

[Authorize]
public class AdminController : Controller { ... }

Atrybut [Authorize] możesz nałożyć na cały kontroler albo na wybraną akcję (np. tylko Delete i Edit, zostawiając Index publiczny). Gdy anonimowy użytkownik spróbuje wejść, Identity automatycznie przekieruje go na /Account/Login i wróci po udanym logowaniu.

Nie myl [Authorize] z [Authorize(Roles = "Admin")]. Pierwszy wymaga tylko zalogowania, drugi — konkretnej roli. Role to temat na osobny odcinek.

Odpalamy aplikację, wylogowujemy się, klikamy „Dodaj produkt" — lądujemy na logowaniu. Logujemy się — wracamy na magazyn, możemy dodawać i usuwać. Wylogowanie znów zamyka drzwi. Dokładnie tego chcieliśmy.

Co dalej

W dziewiątym odcinku spróbujemy skonsumować jakieś zewnętrzne API — pokażę, jak wyciągnąć dane z publicznego serwisu i wyświetlić je w widoku. To samo w sobie jest krokiem w stronę realnych aplikacji, w których rzadko jesteśmy odcięci od zewnętrznego świata.

Jeżeli chcesz, żebym w osobnym materiale rozebrał na części async/await — daj znać w komentarzu pod odcinkiem. Wyszło tu kilka uproszczeń, które wyglądają magicznie, a pod maską są bardzo rozsądne.

Cały kod z tej lekcji znajdziesz na branchu Lekcja-8: github.com/jarmatys/asp-net-core-podstawy/tree/Lekcja-8. Pobierz, uruchom, porównaj ze swoją wersją — czasem najszybciej uczy się na diffie.

Tagi:#course-aspnet#dotnet#identity#authentication

Masz pytanie do tego wpisu?

Napisz do mnie — chętnie porozmawiam o szczegółach technicznych, albo o tym jak zastosować to u Ciebie w projekcie.