Uwierzytelnianie w ASP.NET Core 1.0 na czystym projekcie - ASP.NET Core 1.0 + Angular 2

lipiec 30, 2016

No okej, może z tym czystym projektem to przesadziłem. Mam już trochę rzeczy zrobionych, ale chodzi mi konkretnie o to, że nie startuję z gotowego szablonu MVC. Z tego powodu muszę zatroszczyć się sam o potrzebne pakiety NuGeta i pliki, z których będzie korzystał.

Na razie zamierzam zrobić uwierzytelnianie za pomocą standardowej rejestracji i logowania. Do trzymania danych użytkowników wykorzystam EF. Dokumentacja ASP.NET Core podpowiedziała mi, że będę potrzebował  Microsoft.AspNetCore.Identity.EntityFrameworkCore. Zainstalowałem go i zabrałem się za konfigurację. Przede wszystkim w Startup.cs musimy zarejestrować nową usługę:

public void ConfigureServices(IServiceCollection services)
{
    services.AddIdentity<ApplicationUser, IdentityRole>()
        .AddEntityFrameworkStores<SimplifyContext>()
        .AddDefaultTokenProviders();
}
public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory, IHostingEnvironment env)
{
    app.UseIdentity();
}

Gdzie SimplifyContext do DbContext mojej aplikacji. No i chyba jest okej, ale dostaję informację, że IntelliSense nie może znaleźć ApplicationUser. Okazuje się, że to jest model użytkownika, który powinniśmy dostarczyć. Więc na początek stworzyłem coś prostego, żeby móc iść dalej:

namespace SimplifyApp.Models
{
    public class ApplicationUser : IdentityUser
    {
    }
}

Odpalam kompilację - zero błędów, czuję dobrze. Teraz zaczyna się zabawa, trzeba zrobić formularze rejestracji i logowania z poziomu Angulara i połączyć je z kontrolerami asp.neta.

Istotne: Aby poprawnie korzystać z zapisywania danych o użytkownikach do bazy danych, DbContext aplikacji powinien dziedziczyć po IdentityDbContext<ApplicationUser>.

 

Zacznijmy od rejestracji (formularz rejestracji)

W katalogu komponentów Angulara tworzę sobie folder "register" dla nowego komponentu, a w nim register.html oraz register.ts. Plik szablonu na razie zostawiam i wrzucam standardowe rzeczy do register.ts:

import * as ng from '@angular/core';
import { Http, Headers, HTTP_PROVIDERS } from '@angular/http';

@ng.Component({
    selector: 'register',
    viewProviders: [HTTP_PROVIDERS],
    template: require('./register.html')
})

export class Register {

}

A plik routes.ts informuję o nowym elemencie:

import { Register } from './components/register/register';

export const routes: RouterConfig = [
    { path: 'register', component: Register }
];

Powinno być okej. Dorzucę sobie jeszcze do szablonu "No siema!" i dodam zakładkę z rejestracją do menu, żebym mógł zobaczyć efekt zrobionej roboty:

<li [routerLinkActive]="['link-active']">
    <a [routerLink]="['/register']">
        <span class='glyphicon glyphicon-th-list'></span> Register
    </a>
</li>

Czas przetestować czy wszystko do tej pory działa.

undefined

W takim razie mogę zabrać się za formularz. Będzie to oczywiście na razie prosty form, zawierający trzy elementy: email (który będzie głównym identyfikatorem użytkownika), hasło i jego potwierdzenie. Teraz będzie trochę więcej kodu, ponieważ dodamy od razu prostą frontendową walidację danych. Zacznijmy po kawałku od register.ts:

import * as ng from '@angular/core';
import { Http, Headers, HTTP_PROVIDERS } from '@angular/http';
import {
    FORM_DIRECTIVES,
    REACTIVE_FORM_DIRECTIVES,
    FormControl,
    FormBuilder,
    FormGroup,
    Validators,
    AbstractControl
} from '@angular/forms';

@ng.Component({
    selector: 'register',
    viewProviders: [HTTP_PROVIDERS],
    directives: [FORM_DIRECTIVES, REACTIVE_FORM_DIRECTIVES],
    template: require('./register.html')
})

Dodałem potrzebne komponenty do stworzenia formularza i dołączyłem dyrektywy, które pozwolą na skorzystanie ze zbudowanego dalej w kodzie formularza później w szablonie.

export class Register {
    public http: Http;
    public registerForm: FormGroup;
    public elements: {[name: string]: AbstractControl} = {};

Tworzę zmienną registerForm, która będzie reprezentować wszystkie informacje o formularzu. Jednocześnie, aby uprościć odwoływanie się do elementów formularza, tworzę słownik z referencjami do nich.

constructor(http: Http, fb: FormBuilder) {
    this.http = http;
    const emailRegex = '^[-a-z0-9~!$%^&*_=+}{\'?]+(\.[-a-z0-9~!$%^&*_=+}{\'?]+)*@([a-z0-9_][-a-z0-9_]*(\.[-a-z0-9_]+)' +
    '*\.(aero|arpa|biz|com|coop|edu|gov|info|int|mil|museum|name|net|org|pro|travel|mobi|[a-z][a-z])|([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}))(:[0-9]{1,5})?$';

    this.registerForm = fb.group({
        'email': ['', Validators.compose([Validators.required, Validators.pattern(emailRegex)])],
        'password': ['', Validators.required],
        'confirmPassword': ['', Validators.required]
      },
        { validator: passwordMatch('password', 'confirmPassword') }
    );

    this.elements['email'] = this.registerForm.controls['email'];
    this.elements['password'] = this.registerForm.controls['password'];
    this.elements['confirmPassword'] = this.registerForm.controls["confirmPassword"];
}

Zmienna emailRegex wygląda przerażająco, ale to tylko przeniesienie wytycznych z RFC 5322 i nie trzeba się nim przejmować. Ja go znalazłem w internecie (tutaj) i stwierdziłem, że czemu by go nie użyć.

O wiele ważniejsze jest to, co dzieje się ze zmienną registerForm. Korzystając z metody Form Buildera group tworzymy poszczególne elementy (Form Control) i przypisujemy im właściwości. Najpierw mamy nazwę, a potem tablicę z ustawieniami. Pusty string reprezentuje brak wartości domyślnej. Validators to klasa pozwalająca na tworzenie frontendowej walidacji.

Na cały fb.group nałożony jest też walidator passwordMatch, którego kod znajdzie się poniżej. Jest on nałożony na całą grupę, a nie na jeden element - ponieważ korzysta z kilku elementów (password i confirmPassword).

Na koniec przypisuję sobie skrótowe referencje do pól formularza.

onSubmit(form: any): void {
    console.log('You submitted:', form);
    if (!this.registerForm.valid) console.log('Invalid!');
}

Tutaj mam chwilowo kod do debugowania. Pokazuje mi on w konsoli wysłane wartości i wyświetla "Invalid", gdy walidacja formularza nie przejdzie.

function passwordMatch(passwordElement: string, confirmPasswordElement: string) {
    return (group: FormGroup): { [key: string]: any } => {
        let password = group.controls[passwordElement];
        let confirmPassword = group.controls[confirmPasswordElement];

        if (password.value !== confirmPassword.value) {
            return {
                passwordsMismatch: true
            };
        }
    }
};

Ta prosta funkcja przyjmuje dwa parametry - nazwy pól formularza. Sprawdza czy są one takie same i jeśli nie, zwraca obiekt z flagą passwordsMismatch ustawioną na true.

Teraz pozostało dostosować szablon, żeby wyświetlił formularz i przy wypełnianiu informował użytkownika o możliwie popełnionych błędach.

<h1>Take a step forward, register!</h1>

<style>
    .error input {
        border: 1px #a52a2a solid;
    }
</style>

<form [formGroup]="registerForm" (ngSubmit)="onSubmit(registerForm.value)">
    <div>
        <div class="form-group" [class.error]="!elements['email'].valid && elements['email'].touched">
            <input type="email" class="form-control text-input" id="email"
                   placeholder="Your email" [formControl]="registerForm.controls['email']"/>
        </div>
        <div class="form-group" [class.error]="!elements['password'].valid && elements['password'].touched">
            <input type="password" class="form-control text-input" id="password"
                   placeholder="Password" [formControl]="registerForm.controls['password']"/>
        </div>
        <div class="form-group" [class.error]="!elements['confirmPassword'].valid && elements['confirmPassword'].touched">
            <input type="password" class="form-control text-input" id="confirmPassword"
                   placeholder="Confirm password" [formControl]="registerForm.controls['confirmPassword']"/>
            <div *ngIf="registerForm.hasError('passwordsMismatch')">Passwords do not match!</div>
        </div>
    </div>
    <div>
        <div>
            <button type="submit" class="btn btn-success">Send</button>
        </div>
    </div>
</form>

Przede wszystkim [formGroup] pozwala na połączenie tego formularza z kodem, który znajduje się w pliku .ts. (ngSubmit) pozwala na przypisanie wywołania funkcji do eventu wysłania formularza. Dalej używam [formControl], aby dopasować pole do elementu formularza, który stworzyłem w pliku .ts.

Reszta kodu Angulara odpowiada za wyświetlanie walidacji.

undefined

Walidacja frontendowa z głowy. Teraz czas na wysłanie obiektu z danymi do backendu. Aby to zrobić, wystarczy lekko zmodyfikować metodę onSubmit:

onSubmit(form: any): void {
    console.log('you submitted value:', form);
    if (!this.registerForm.valid) console.log('Invalid!');
    else {
        let headers = new Headers({ 'Content-Type': 'application/json' });
        this.http.post('/api/Account/Register', JSON.stringify(form), { headers: headers })
            .subscribe(result => {
                console.log(result.toString());
            });;
        console.log(JSON.stringify(form));
    }
}

Mamy już dane, teraz trzeba je przechwycić i przetworzyć.

 

Czas na backend rejestracji

Zacznijmy od stworzenia View Modelu, który będzie odpowiedzialny za przechwycenie danych i walidację backendową. Nie będzie to nic skomplikowanego, w zasadzie powtórzenie tego co było w frontendzie. W tym celu skorzystam z System.ComponentModel.DataAnnotations:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;

namespace SimplifyApp.Models.ViewModels
{
    public class RegisterViewModel
    {
        [Required]
        [EmailAddress]
        public string Email { get; set; }

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

        [DataType(DataType.Password)]
        [Compare("Password")]
        public string ConfirmPassword { get; set; }
    }
}

To powinno wystarczyć. Teraz trzeba zabrać się za kontroler, który obsłuży wszystkie akcje związane z uwierzytelnianiem. Tutaj będę się sugerował dokumentacją, starając się dostosowywać kod do współpracy z Angularem. Najpierw formalności:

namespace SimplifyApp.Controllers
{
    [Authorize]
    [Produces("application/json")]
    public class AccountController : Controller
    {
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly ILogger _logger;

        public AccountController(
            UserManager<ApplicationUser> userManager,
            SignInManager<ApplicationUser> signInManager,
            ILoggerFactory loggerFactory)
        {
            _userManager = userManager;
            _signInManager = signInManager;
            _logger = loggerFactory.CreateLogger<AccountController>();
        }
     }
}

Czyli w zasadzie przepisana dokumentacja. Czas odebrać dane z Angulara, na razie bez wykonywania na nich żadnych operacji. Sprawdzimy po prostu czy komunikacja działa. Tworzę metodę Register w AccountController:

// POST: api/Account/Register
[HttpPost]
[AllowAnonymous]
[Route("api/Account/Register")]
public async Task<IActionResult> Register([FromBody] RegisterViewModel model) {
    return Ok();
}

Ustawiam sobie debugger mark na linijkę z nagłówkiem funkcji i odpalam aplikację. Chcę sprawdzić, czy dane z formularza zostaną przesłane do kontrolera.

Wypełniam formularz i wysyłam:

undefined

Visual Studio krzyczy, że trafił na debugger mark. No to sprawdzam jakie wartości kryją się pod zmienną model:

undefined

Klikam Continue w VS i ostatecznie w konsoli widzę:

undefined

Komunikacja działa. Teraz trzeba już tylko zweryfikować dane z modelu i zarejestrować nowego użytkownika.

Walidacja backendowa będzie prosta. Wykorzystamy stworzony ViewModel. Żeby móc sprawdzić, czy działa, będzie trzeba na chwilę wyłączyć walidację frontendową (żeby móc wysłać POST z błędnymi danymi). Załóżmy, że gdy dane będą poprawne zwrócimy status 200, a gdy niepoprawny - status 418 aka "Jestem czajnikiem".

// POST: api/Account/Register
[HttpPost]
[AllowAnonymous]
[Route("api/Account/Register")]
public async Task<IActionResult> Register([FromBody] RegisterViewModel model) {
    if (ModelState.IsValid) {
        return Ok();
    }
    else {
        return StatusCode(418);
    }
}

Testując teraz wysyłanie formularza widać, że walidacja działa.

undefined

Korzystając z dokumentacji można dosyć łatwo dopisać resztę. Spróbuję z czymś takim:

// POST: api/Account/Register
[HttpPost]
[AllowAnonymous]
[Route("api/Account/Register")]
public async Task<IActionResult> Register([FromBody] RegisterViewModel model) {
    if (ModelState.IsValid) {
        var user = new ApplicationUser { UserName = model.Email, Email = model.Email };
        var result = await _userManager.CreateAsync(user, model.Password);
        if (result.Succeeded) {
            await _signInManager.SignInAsync(user, isPersistent: false);
            _logger.LogInformation(3, "User created a new account with password.");
            return Ok();
        }
        else {
            return StatusCode(503);
        }
    }
    else {
        return StatusCode(400);
    }
}

Widać, że jeżeli wszystko pójdzie według planu, Angular powinien poinformować o otrzymaniu kodu statusu 200. Wtedy będzie można zajrzeć do bazy i sprawdzić, czy nowy użytkownik faktycznie się pojawił.

Otrzymałem błąd 503. Dzięki debugowaniu dowiedziałem się, że Identity nakłada pewne wymagania na hasło użytkownika. Między innymi to, że hasło musi posiadać co najmniej jedną wielką literę i jeden znak nie alfanumeryczny. Jeżeli się z tym nie zgadzamy, możemy zmienić to w Startup.cs:

services.AddIdentity<ApplicationUser, IdentityRole>(o => {
    // configure identity options
    o.Password.RequireDigit = false;
    o.Password.RequireLowercase = false;
    o.Password.RequireUppercase = false;
    o.Password.RequireNonAlphanumeric = false; ;
    o.Password.RequiredLength = 6;
    })
    .AddEntityFrameworkStores<SimplifyContext>()
    .AddDefaultTokenProviders();

Wyłączenie tych opcji było mi potrzebne, aby ustawić swoje standardowe hasło admin1. No to wypełniam formularz jeszcze raz:

undefined

Zerkam do bazy danych:

undefined

Na razie wydaje się być w porządku. Czy wszystko działa okaże się przy próbie zalogowania.

 

To teraz zostało logowanie...

Jeżeli chodzi o formularz, to tutaj nie ma nowych odkryć. Zrobiłem go tak samo jak poprzedni, tylko bez powtarzania hasła i z prostszą walidacją. Tutaj efekt końcowy (tak jak wcześniej ustawiłem sobie pokazywanie informacji w konsoli o podejmowanych działaniach, żebym mógł upewnić się, że formularz działa).

undefined

Teraz czas przekierować wysyłany przez niego obiekt w kierunku api/Account/Login. Z tym formularzem będzie o tyle ciekawsza sprawa, że odpowiedź z serwera będzie trzeba przedstawić użytkownikowi. To backend będzie odpowiedzialny za sprawdzanie poprawności wprowadzonych danych.

Najpierw stworzę LoginViewModel, który pozwoli zarządzać modelem logowania:

namespace SimplifyApp.Models.ViewModels
{
    public class LoginViewModel
    {
        [Required]
        [EmailAddress]
        public string Email { get; set; }

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

I korzystam z niego w kontrolerze:

// POST: api/Account/Login
[HttpPost]
[AllowAnonymous]
[Route("api/Account/Login")]
public async Task<IActionResult> Login([FromBody] LoginViewModel model)
{
    if (ModelState.IsValid) {
        var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: false);
        if (result.Succeeded) {
            _logger.LogInformation(1, "User logged in.");
            return Ok();
        }
        else
            return StatusCode(418);
    }
    else {
        return StatusCode(400);
    }
}

Dzięki temu, jak zobaczę w konsoli status 200 - to znaczy, że logowanie się powiodło. Sprawdźmy to.

undefined

Pojawia się też ciasteczko świadczące o pozytywnym uwierzytelnieniu:

undefined

 

Oczywiście to jest na razie sam początek. To co na razie przychodzi mi na myśl do ogarnięcia to: 

  • niezabezpieczone zapytania do API (muszę zrobić tokeny uwierzytelniające),
  • kontrolery, które zwracają JSON zamiast zwykłego http status (dzięki temu można lepiej poinformować o błędzie, który wystąpił - np. dublujące się adresy email w bazie przy rejestracji),
  • najważniejsze: korzystanie z uwierzytelnienia w Angularze 2 - no bo nie mogę się przy każdej akcji pytać o email/hasło w celu wykonania zapytania.

Jak się za to zabiorę, to opiszę swoje postępy w kolejnym wpisie.

Dzięki za odwiedzenie mojego bloga!