Social Cooking – autentykacja w WebAPI

Autoryzacja czy autentykacja? Właściwie powinienem napisać autoryzacja czy uwierzytelnianie, ale jakoś już się przyjęło to spolszczenie w naszym języku. Pojęcia bardzo często używane wśród developerów i bardzo często mylone ze sobą. Muszę przyznać, że są w moim odczuciu trochę nieintuicyjne. Mam wrażenie, że te słowa nie oddają tego co się za nimi kryje. Jeśli i Wy macie często z nimi problem to zapraszam do przeczytania dalszej części tego posta. Dzisiaj przybliżę nieco te dwa mechanizmy i zobaczycie w jaki sposób będzie zaimplementowany jeden z nich.

W systemach informatycznych, ale również w życiu codziennym korzystamy z 3 kroków mających potwierdzić nasze prawa do wykonania konkretnej czynności. Są to:

  • identyfikacja
  • uwierzytelnienie (autentykacja)
  • autoryzacja

 

Identyfikacja to proces w którym deklarujemy swoją tożsamość.

  • W życiu codziennym używamy identyfikacji kiedy na przykład dzwonimy do firmy z usług której korzystamy i przedstawiamy się imieniem i nazwiskiem. Pracownik po drugiej stronie na podstawie tej informacji ma możliwość potwierdzić, że faktycznie jesteśmy klientami firmy.
  • W systemach informatycznych identyfikujemy się zazwyczaj za pomocą loginu np. pisząc komentarze na blogach.

 

Uwierzytelnienie (ang. authentication – stąd polska autentykacja) to proces w którym strona zaufana jest w stanie zweryfikować naszą wcześniej podaną tożsamość.

  • W życiu codziennym uwierzytelniamy się dzwoniąc na infolinię banku i po identyfikacji (czyli przedstawieniu się) podajemy wcześniej ustalone hasło.
  • W systemach informatycznych zazwyczaj identyfikacja i uwierzytelnienie są połączone w jeden mechanizm. Na stronie logowania wprowadzamy nasz login oraz hasło po czym naciskamy przycisk Zaloguj.

 

Autoryzacja to proces w którym potwierdzamy czy dany podmiot jest uprawniony do wykonywania określonych działań. Autoryzacja przeprowadzana jest zawsze po udanej identyfikacji i uwierzytelnieniu.

  • W życiu codziennym ponownie można przytoczyć rozmowę z infolinią banku. W tym przypadku jednak nie dzwoniący dokonuje autoryzacji, a pracownik który sprawdza w systemie czy klient ma prawo na przykład zlecić przelew bankowy telefonicznie.
  • W systemach informatycznych zazwyczaj w tym przypadku mówimy o rolach użytkownika. Osoba odwiedzająca witrynę internetową może być Gościem, Użytkownikiem, Administratorem itd. Na podstawie roli system decyduje czy odwiedzający może wyświetlić daną stronę, albo zmodyfikować jej treść.

Tyle teorii. Pora wracać do projektu.

Tak jak już wcześniej wspominałem strasznie nie lubię tej części projektów. Zawsze zostawiam to na później przez co całą masę rzeczy muszę przerabiać. Dlatego też tym razem podejdę do tego inaczej.

Założenia

W celu obsługi uwierzytelnienia wykorzystam sporo bibliotek:

  • Microsoft.OWIN – tworzący wspólny interfejs pomiędzy back-endem i front-endem.
  • Microsoft.OWIN.Security – biblioteka zbudowana ponad OWINem zabezpieczająca proces uwierzytelnienia:
    • OAuth – część OWIN.Security, która standaryzuje proces autoryzacji. Wspiera obsługę tokenów na czym bardzo mi zależy.
    • Facebook, Google – częścią OWIN.Security są również biblioteki pomagające w autoryzacji za pomocą zewnętrznych systemów takich jak Facebook czy Google.
  • Microsoft.OWIN.CORS – CORS, czyli Cross Origin Resource Sharing jest biblioteką pozwalającą na zarządzanie dostępem do API z różnych systemów, a także z odpowiednim IP.
  • Microsoft.AspNet.Identity – standardowa biblioteka do wspomagania pracy z użytkownikami.

 

Proces autoryzacji będzie opierał się o standardowy mechanizm Identity, który na chwilę obecną będzie jedynie rozszerzony o obsługę czasowych tokenów.

Tokeny będą aktywne przez 30 minut. W tym czasie system nie będzie prosił o ponowne uwierzytelnienie.

Co z tego wyjdzie?

Na koniec tego posta będę miał WebAPI gotowe na to żeby uwierzytelnić użytkowników odwiedzających SocialCooking. Na razie to wszystko. Dopiero w kolejnym poście pokażę jak to wszystko połączyć z Angularem.

Zaczynamy!

Po ściągnięciu wszystkich potrzebnych bibliotek (wypisanych powyżej i powiązanych z nimi) z NuGet’a mogę przejść w końcu do kodowania 🙂

Startup

Na dobry początek trzeba utworzyć miejsce z którego WebAPI będzie startowało. Tworzę klasę Startup.cs, która na początek będzie wyglądała tak:

[assembly: OwinStartup(typeof(Startup))]
namespace SocialCooking.API
{
    public class Startup
    {
      public void Configuration(IAppBuilder app)
        {

            HttpConfiguration config = new HttpConfiguration();
            ConfigureOAuth(app);
            WebApiConfig.Register(config);
            app.UseCors(Microsoft.Owin.Cors.CorsOptions.AllowAll);
            app.UseWebApi(config);
        }
public void ConfigureOAuth(IAppBuilder app)
        {
}
    }
}

To co tutaj napisałem nie jest niczym skomplikowanym. Pierwsza linijka kodu z adnotacją dotyczącą assembly informuje nas o tym, że właśnie ta klasa będzie wywoływana przy starcie WebAPI.

Następnie do metody Configuration przekazujemy IAppBuilder, który będzie tworzył naszą aplikację dla serwera OWIN.

Kolejnym krokiem jest stworzenie obiektu HttpConfiguration, który będzie trzymał informacje między innymi o tym jak wyglądają zasady routingu naszej aplikacji. Zasady te podaję 2 linijki dalej. Klasa WebApiConfig została umieszczona w folderze App_Start i wygląda następująco:

namespace SocialCooking.API
{
    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            config.MapHttpAttributeRoutes();

            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
                );
            var jsonFormatter = config.Formatters.OfType<JsonMediaTypeFormatter>().First();
            jsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();

        }
    }
}

Moje „trasy” zostawiam na razie bez żadnych zmian. Jedyne co dodaję to formater Jsona, który później okaże się niezwykle pomocny przy odbieraniu obiektów z Angulara.

Wrócę jeszcze na dwie chwile do klasy Startup.

Ostatnie dwie linijki umożliwiają użycie CORS oraz przekazują konfigurację do interfejsu IAppBuilder.

Uważniejsi czytelnicy zauważą na pewno, że nie opisałem jeszcze metody ConfigureOAuth. Przez chwilę zostawię ją pustą, ale niedługo posłuży mi ona do konfiguracji dostępu poprzez OAuth oraz przez zewnętrzne systemy (na początek Google oraz Facebook).

Context i Repozytorium

Dalej zajmę się stworzeniem kontekstu bazy danych oraz repozytorium odpowiedzialne za użytkowników (rejestracja, logowanie, wyszukiwanie). W tym celu potrzebuję 2 klas:

  • AuthContext.cs
  • AuthRepository.cs
namespace SocialCooking.API.Models
{
    public class AuthContext : IdentityDbContext<IdentityUser>
    {
        public AuthContext()
            : base("SCContext")
        {
        }

        public DbSet<AppClient> AppClients { get; set; }
        public DbSet<RefreshToken> RefreshTokens { get; set; }
    }
}

Kontekst nie kryje w sobie w zasadzie nic szczególnie interesującego. Może oprócz tego, że dziedziczy po IdentityDbContext dzięki czemu zapewnione zostaje mapowanie code-first do bazy danych.

namespace SocialCooking.API
{
    public class AuthRepository : IDisposable
    {
        private readonly AuthContext _ctx;

        private readonly UserManager<IdentityUser> _userManager;

        public AuthRepository()
        {
            _ctx = new AuthContext();
            _userManager = new UserManager<IdentityUser>(new UserStore<IdentityUser>(_ctx));
        }

        public async Task<IdentityResult> RegisterUser(UserModel userModel)
        {
            IdentityUser user = new IdentityUser
            {
                UserName = userModel.UserName
            };

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

            return result;
        }
.
.
.
 public async Task<bool> AddRefreshToken(RefreshToken token)
        {

            var existingToken = _ctx.RefreshTokens.SingleOrDefault(r => r.Subject == token.Subject && r.ClientId == token.ClientId);

            if (existingToken != null)
            {
                var result = await RemoveRefreshToken(existingToken);
            }

            _ctx.RefreshTokens.Add(token);

            return await _ctx.SaveChangesAsync() > 0;
        }

        public async Task<bool> RemoveRefreshToken(string refreshTokenId)
        {
            var refreshToken = await _ctx.RefreshTokens.FindAsync(refreshTokenId);

            if (refreshToken != null)
            {
                _ctx.RefreshTokens.Remove(refreshToken);
                return await _ctx.SaveChangesAsync() > 0;
            }

            return false;
        }
.
.
.
    }
}

Zdecydowanie ciekawszą klasą jest repozytorium. Będzie ono standardowo odpowiedzialne za tworzenie, usuwanie oraz wyszukiwanie użytkowników, a także obsługę tokenów.

W tym momencie pozostały dwa ostatnie kroki potrzebne do prawidłowej obsługi użytkowników.

Controller

namespace SocialCooking.API.Controllers
{
    [RoutePrefix("api/Account")]
    public class AccountController : ApiController
    {
        private readonly AuthRepository _repo = null;

        private IAuthenticationManager Authentication
        {
            get { return Request.GetOwinContext().Authentication; }
        }

        public AccountController()
        {
            _repo = new AuthRepository();
        }

        [AllowAnonymous]
        [Route("Register")]
        public async Task<IHttpActionResult> Register(UserModel userModel)

        [OverrideAuthentication]
        [HostAuthentication(DefaultAuthenticationTypes.ExternalCookie)]
        [AllowAnonymous]
        [Route("ExternalLogin", Name = "ExternalLogin")]
        public async Task<IHttpActionResult> GetExternalLogin(string provider, string error = null)

        [AllowAnonymous]
        [Route("RegisterExternal")]
        public async Task<IHttpActionResult> RegisterExternal(RegisterExternalBindingModel model)

        [AllowAnonymous]
        [HttpGet]
        [Route("ObtainLocalAccessToken")]
        public async Task<IHttpActionResult> ObtainLocalAccessToken(string provider, string externalAccessToken)

        private JObject GenerateLocalAccessTokenResponse(string userName)

Żeby łatwiej było omówić controller nie wrzucam całej jego treści. (Tą możecie zawsze znaleźć na GitHubie)

Krótko o kontrolerze. Odpowiada za przyjęcie żądań logowania, rejestracji oraz odświeżania tokenów. Co oczywiste przy metodach dotyczących rejestracji dodaję adnotację [AllowAnonymous], aby nowi użytkownicy byli w stanie się zarejestrować.

Metody ObtainLocalAccessToken oraz GenerateLocalAccessTokenResponse odpowiadają za przyznanie tokena długoterminowego (cały mechanizm tokenów opiszę na koniec tego posta).

Providery

Aby spełnić wcześniej postawione założenia będę potrzebował czterech providerów:

  • SimpleAuthorizationServerProvider – będzie to prosty provider na potrzeby OAuth odpowiedzialny za standardową rejestrację na stronie
  • FacebookAuthProvider oraz GoogleAuthProvider – provider na potrzeby rejestracji przez zewnętrzne systemy
  • SimpleRefreshTokenProvider – provider w którym dzieje się cała magia dotycząca tokenów.

Tutaj postaram się opisać jedynie pierwszy i ostatni, gdyż 2 związane z Facebookiem i Googlem są standardowo napisanymi providerami.

SimpleAuthorizationServerProvider jest klasą, która będzie dziedziczyć po OAuthAuthorizationServerProvider. Oznacza to, że jest jej rozszerzeniem związanym z moją specyficzną implementacją. Odpowiada on za walidację aplikacji żądającej dostępu, a także za stworzenie endpointa do tworzenia tokenów, które będą posiadały informacje dotyczące praw użytkownika.

SimpleRefreshTokenProvider implementuje interfejs IAuthenticationTokenProvider i odpowiada za tworzenie i odnawianie tokenów.

Oba providery są jedynie rozszerzeniem standardowych mechanizmów dostarczanych przez OWIN.

Po tym wszystkim muszę wrócić jeszcze na sam początek do klasy Startup.cs. W końcu mogę uzupełnić metodę ConfigureOAuth i teraz wygląda ona tak:

public void ConfigureOAuth(IAppBuilder app)
        {
            app.UseExternalSignInCookie(Microsoft.AspNet.Identity.DefaultAuthenticationTypes.ExternalCookie);
            OAuthBearerOptions = new OAuthBearerAuthenticationOptions();

            OAuthAuthorizationServerOptions oAuthServerOptions = new OAuthAuthorizationServerOptions()
            {

                AllowInsecureHttp = true,
                TokenEndpointPath = new PathString("/token"),
                AccessTokenExpireTimeSpan = TimeSpan.FromMinutes(30),
                Provider = new SimpleAuthorizationServerProvider(),
                RefreshTokenProvider = new SimpleRefreshTokenProvider()
            };

            // Token Generation
            app.UseOAuthAuthorizationServer(oAuthServerOptions);
            app.UseOAuthBearerAuthentication(OAuthBearerOptions);

            //Configure Google External Login
            GoogleAuthOptions = new GoogleOAuth2AuthenticationOptions()
            {
                ClientId = "xxxxxx",
                ClientSecret = "xxxxxx",
                Provider = new GoogleAuthProvider()
            };
            app.UseGoogleAuthentication(GoogleAuthOptions);

            //Configure Facebook External Login
            FacebookAuthOptions = new FacebookAuthenticationOptions()
            {
                AppId = "xxxxxx",
                AppSecret = "xxxxxx",
                Provider = new FacebookAuthProvider()
            };
            app.UseFacebookAuthentication(FacebookAuthOptions);

        }

Tokeny

Zdecydowanie najciekawszym punktem tego mechanizmu są Tokeny.

Każdy użytkownik posiada 2 tokeny:

  1. Token krótkoterminowy – zawiera „pozwolenie” na użytkowanie portalu na krótki czas (30 minut). W tym czasie aplikacja nie będzie potrzebowała odświeżenia uwierzytelnienia.
  2. Token długoterminowy – jest to tylko i wyłącznie identyfikator na podstawie którego system udziela kolejnych tokenów krótkoterminowych. Kiedy pierwszy token staje się przeterminowany, aplikacja na podstawie tokena długoterminowego odświeża krótkoterminowy.

Dzięki takiemu podejściu aplikacja, która jest w stanie bezczynności nie wyloguje użytkownika (będzie potrzebny jedynie dodatkowy request odświeżający token), a także po ponownym odwiedzeniu strony (w czasie aktywności tokena długoterminowego) nie będzie on musiał wpisywać loginu i hasła.

Oprócz odpowiedniego zaprogramowania tego mechanizmu potrzebuję wprowadzić pewne zmiany w bazie danych. Tworzę dwie nowe tabele o nazwach AppClients oraz RefreshTokens. (Strukturę tabel możecie obejrzeć na GitHubie w katalogu Models)

Pierwsza z nich będzie trzymała informacje o tym jakie aplikacje i z jakiego adresu mogą połączyć się z API. Na chwilę obecną będzie to jedynie aplikacja napisana w AngularJS, ale w przyszłości bez bólu portal może zostać rozszerzony o np. aplikację mobilną.

Druga natomiast będzie przetrzymywała informacje o przyznanych tokenach. Dla każdego użytkownika będzie istniał Token o konkretnym Id, a także Ticket, który będzie w sposób zaszyfrowany przetrzymywał informacje o claimsach (po więcej informacji na temat działania Identity Claims odsyłam do artykułu Claims).

Chyba tyle

Wydaje się, że mój serwer autoryzacyjny jest już gotowy. W następnym poście opiszę jak to wszystko spiąć z aplikacją Angularową.

Wiem, że większość tematów jedynie nakreśliłem nie tłumacząc konkretnie co się dzieje. Jeżeli interesuje Was jakaś kwestia szczególnie to dajcie znać, a na pewno rozwinę dany temat w innym poście.

Related posts

  • Pingback: dotnetomaniak.pl()

  • Pingback: Social Cooking – autentykacja w AngularJS – Wildpost()

  • Stefan474

    Żałuję że nie znalazłem tego artykułu wcześniej… Kupę czasu zmarnowałem na implementację logowania przez webAPI, a to wszystko przez brak jakiekolwiek dokumentacji na ten temat. Wydawało mi się że skoro angular jest taki popularny to nie będzie z tym problemu, no ale cóż, nigdy nie wiadomo czego można się spodziewać po rozwiązaniach microsoftu

    • Kajetan Duszyński

      Teraz już będzie do czego wracać 🙂 ale faktycznie zgadzam się, że ciężko coś sensownego znaleźć. Sam zanim się za to wziąłem sporo się naszukałem dobrego przykładu.