Możesz skomentować ten wpis w serwisach społecznościowych: Linkedin (przejdź) lub Facebook (przejdź).
Witam,
w tym wpisie chciałem opisać historię swoich „zmagań” z ASP.NET Identity. Myślę że niektórzy programiści mogą z tych zmagań skorzystać i użyć ich efektów we własnych aplikacjach. Jest to także historyjka ciekawa, bo jest to jeden z epizodów – wprawdzie nie najistotniejszy – ale jeden z powodów, dla których moja ścieżka programistyczna potoczyła się tak a nie inaczej.
Wszystko zaczyna się wiosną 2016 r. Moja wiedza o „dużych” bibliotekach czy też frameworkach służących do budowy aplikacji była wówczas bardzo niewielka. Właśnie planowałem sobie aplikację do mojej inżynierki, a jedną z decyzji jakie miałem podjąć, dotyczyła technologii z której skorzystam przy budowie tej aplikacji. W grę wchodziła wówczas Java – a dokładniej zbudowany na jej bazie Spring Framework – oraz środowisko ASP.NET na bazie języka C#.
Obie decyzje były na tamten moment ryzykowne. Ponieważ chciałem, aby moja aplikacja – a dokładniej strona internetowa – nie wymagała przeładowywania strony za każdym razem kiedy użytkownik coś zmienia (tzw. single-page application), potrzebowałem jakiegoś mechanizmu logowania i obsługi kont użytkowników, który zadziała w warunkach REST API (a zatem standardowy formularz logowania nie wchodził w grę). Początkowo próbowałem wstępnie „okiełznać” oba frameworki. O ile się nie mylę, na bardzo wczesnym etapie rozważałem nawet jednoczesne tworzenie API w obydwu technologiach. Inną możliwością był wybór tej biblioteki, którą łatwiej dopasuję do swoich oczekiwań.
W przypadku Springa, przez długi czas barierą nie do pokonania było skonfigurowanie oAuth’a, który miał mi posłużyć do obsługi logowania użytkowników. Z kolei w przypadku ASP.NET problematycznym okazał się moduł ASP.NET Identity.
W wielkim skrócie, ASP.NET Identity – wraz z innymi komponentami ASP.NET – pozwala dość szybko zaprogramować i skonfigurować całą obsługę kont użytkowników, wraz z możliwością wyboru czy chcemy korzystać ze standardowych formularzy logowania, z lokalnego oAuth’a czy też może z zewnętrznego podmiotu uwierzytelniającego takiego jak np. Google czy Facebook. Niemalże automatycznie pozwala też zapisać wszystkie informacje o użytkownikach do bazy danych. Potrzebne do tego jest jedynie skonfigurowanie klasy DbContext, a następnie wykonanie tzw. migracji, która zapisze naszą strukturę danych w rzeczywistej bazie.
Okazało się jednak że tak zbudowana biblioteka wymusza pewną strukturę klasy odzwierciedlającej dane użytkownika systemu. Co gorsza, ta struktura jest zapisywana w bazie danych. Przyczyną tego stanu rzeczy jest fakt, iż domyślny sposób wykorzystania ASP.NET Identity zakłada że klasa reprezentująca użytkownika będzie dziedziczyć po klasie IdentityUser – na co wskazuje nagłówek klasy IdentityDbContext, będącej bazową konfiguracja bazy danych:
public class IdentityDbContext<TUser> : IdentityDbContext<TUser, IdentityRole, string> where TUser : IdentityUser
Źródło: repozytorium ASP.NET Core w serwisie github.com
Z kolei klasa IdentityUser już na samym starcie zawiera całkiem sporo pól. Na każde z nich musi zostać zarezerwowane miejsce w bazie danych.
W efekcie, tabela użytkowników w naszej bazie danych będzie wyglądała mniej więcej tak:
1> SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'AspNetUsers';
2> GO
COLUMN_NAME
--------------------
Id
AccessFailedCount
ConcurrencyStamp
Email
EmailConfirmed
LockoutEnabled
LockoutEnd
NormalizedEmail
NormalizedUserName
PasswordHash
PhoneNumber
PhoneNumberConfirmed
SecurityStamp
TwoFactorEnabled
UserName
(15 rows affected)
Powyższy kod jest zrzutem z konsoli SQL Server w mojej przykładowej bazie danych, która została przygotowana na bazie instancji klasy IdentityUser. Wykonałem tutaj polecenie odczytu nazw wszystkich kolumn w tabeli użytkowników.
I teraz pytanie do Czytelników:
Czy naprawdę każdy z Was w swojej aplikacji potrzebuje pól takich jak: LockoutEnabled, LockoutEnd? A SecurityStamp? ConcurrencyStamp? Są to kwestie dotyczące aktywowania lub dezaktywowania konta użytkowników oraz znaczniki związane z dodawaniem nowego konta użytkownika lub zmianą jego hasła. Jeśli ktoś chce korzystać – droga wolna, ale dla mnie to kompletny bullshit.
AccessFailedCount? Liczba błędnych logowań na konto danego użytkownika. TwoFactorEnabled? Logowanie dwuskładnikowe. Te rzeczy mogą się przydać przy systemach szczególnie wrażliwych na bezpieczeństwo, ale mało prawdopodobne by interesowały twórcę przeciętnej strony internetowej.
Podobnie EmailConfirmed czy też NormalizedEmail i NormalizedUserName.
Nawet pola dotyczące numeru telefonu (PhoneNumber i PhoneNumberConfirmed) nie muszą być konieczne w każdej aplikacji. Nie mam problemu wyobrazić sobie prostej aplikacji, która nie potrzebuje zbierać takich danych. Zwłaszcza po wprowadzeniu RODO mówiło się wiele o tym, by nie zbierać danych których nie musimy używać.
A więc kiedy wspomnianą wiosną 2016 r. zorientowałem się że te wszystkie pola musiałyby siedzieć w bazie danych, zacząłem się zastanawiać, co by tu zrobić żeby się ich pozbyć i korzystać wyłącznie z tych, które są mi rzeczywiście potrzebne.
W tamtym okresie miałem kontakt ze znajomym, który kończył studia i zaczynał się zajmować zawodowo programowaniem w .NET. Jednak na pytanie co zrobić aby pozbyć się niechcianych pól, stwierdził: co mi te pola przeszkadzają, że nie muszę ich wypełniać danymi, że to nie zajmuje zbyt wiele miejsca w bazie danych, że musiałbym nadpisać kilka komponentów w Identity i w ogóle tak się nie powinno robić. Można powiedzieć iż dał mi do zrozumienia że mi nijak nie pomoże.
Cóż począć… Nie umiałem sobie jeszcze poradzić z tym samodzielnie. Szczególnie że w owym okresie open-source’owy .NET Core był w powijakach. Kojarzę że nawet nie miał jeszcze pierwszej wersji stabilnej, a jedynie testową. Moje ówczesne próby napisania aplikacji do inżynierki dotyczyły jeszcze poprzedniego, Windowsowego środowiska ASP.NET, który nie miał otwartego kodu. Nie mogłem sobie podejrzeć źródła klas, które powinienem nadpisać aby pozbyć się niechcianych pól IdentityUser’a. Mogłem znaleźć jedynie ich nagłówki, a także dokumentację opisującą wprawdzie zastosowanie tych klas, ale bez dokładnej implementacji.
Jakiś czas później udało mi się uporać z problemami które uniemożliwiały mi prawidłowe skonfigurowanie uwierzytelniania w aplikacji Springowej i finalnie aplikacja do pracy inżynierskiej powstała w tym właśnie frameworku – w języku Java. Kwestia „okiełznania” ASP.NET Identity została zaś odłożona ad acta.
Krótko po obronieniu mojej pracy inżynierskiej napisałem nawet dosyć prostą aplikację REST API w środowisku ASP.NET Core. Jednak pomny przykrych doświadczeń z ASP.NET Identity, tamtym razem nie skorzystałem w ogóle z Identity, decydując się w tym względzie na jedno z alternatywnych rozwiązań.
…
Od czasu nieudanej próby użycia ASP.NET Identity minęło już kilka lat. Oczywiście przez ten czas wiele się zmieniło. Spędziłem wiele czasu, pracując nad swoją pracą magisterską, która dotyczy porównania Spring Framework i ASP.NET Core w zakresie możliwości jakie każdy z nich daje programiście przy tworzeniu aplikacji sieciowej, głównie REST API. Przy tworzeniu tejże pracy niejednokrotnie analizowałem dokumentację, a nawet kod źródłowy każdego z omawianych frameworków. (Na szczęście, dzisiaj ASP.NET w opensource’owym wydaniu Core ma się o wiele lepiej. Nie ma problemu z dotarciem do kodu źródłowego, a i jakość dokumentacji czy literatury jemu poświęconej jest znacznie lepsza – choć moim zdaniem ASP.NET nadal ustępuje w tym względzie Springowi.)
Już od jakiegoś czasu dochodziłem do wniosku że tym razem stać będzie mnie na samodzielne zmierzenie się z wyzwaniem dokonania drobnych modyfikacji dzięki którym będę mógł wykorzystać możliwości ASP.NET Identity, ale moja klasa (a zarazem tabela bazodanowa) użytkownika będzie zawierała te i tylko te pola, które uznam za potrzebne.
Poniżej opiszę, jak te zmagania wyglądały i co należy wykonać aby założony cel osiągnąć.
Przyjrzyjmy się najpierw, jak wygląda proces włączania modułu Identity do naszego projektu. Zajrzyjmy do źródła (https://github.com/aspnet/Identity/):
Jeśli zajrzymy do kodu typowej aplikacji sieciowej wykorzystującej Identity, np. jednego z szablonów generowanych w Visual Studio, w metodzie ConfigureServices klasy Startup pojawia się coś w tym stylu:
services.AddIdentity<ApplicationUser, IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>().AddDefaultTokenProviders();
albo takim:
services.AddDefaultIdentity<IdentityUser>().AddDefaultUI(UIFramework.Bootstrap4).AddEntityFrameworkStores<ApplicationDbContext>();
Metodę .AddIdentity<>() możemy odnaleźć w klasie IdentityServiceCollectionExtensions. Nagłówek tej metody wygląda następująco:
public static IdentityBuilder AddIdentity(this IServiceCollection services) where TUser : class where TRole : class => services.AddIdentity(setupAction: null);
[…]
public static IdentityBuilder AddIdentity(this IServiceCollection services, Action setupAction) where TUser : class where TRole : class
{
[…]
}
W tej klasie nie pojawia się odwołanie do klasy IdentityUser, której instancję chcemy usunąć. Klasa użytkownika i jego roli może być dowolną klasą. A zatem tej metody nie musimy ruszać.
Podobnie jest z alternatywną metodą AddDefaultIdentity<>(), której definicję znajdziemy w klasie IdentityServiceCollectionUIExtensions:
public static IdentityBuilder AddDefaultIdentity(this IServiceCollection services, Action configureOptions) where TUser : class
{
[…]
}
Wewnątrz tej metody znajdziemy odwołanie do metody AddIdentityCore<>(), więc pozwolę sobie na pokazanie nagłówka również tej metody (klasa IdentityServiceCollectionExtensions> /znajduje się w innym pakiecie nazw niż pierwsza wymieniona klasa o tej nazwie – to nie jest to samo!/:)
public static IdentityBuilder AddIdentityCore(this IServiceCollection services) where TUser : class => services.AddIdentityCore(o => { });
[...]
public static IdentityBuilder AddIdentityCore(this IServiceCollection services, Action setupAction) where TUser : class
{
[...]
}
Metody AddDefaultTokenProviders() (klasa IdentityBuilderExtensions) oraz AddDefaultUI() (klasa IdentityBuilderUIExtensions) także nie mają nic wspólnego z klasą IdentityUser.
Najwięcej do zrobienia będzie z metodą AddEntityFrameworkStores<>() – klasa IdentityEntityFrameworkBuilderExtensions. Wygląda ona tak:
public static IdentityBuilder AddEntityFrameworkStores(this IdentityBuilder builder)
where TContext : DbContext
{
AddStores(builder.Services, builder.UserType, builder.RoleType, typeof(TContext));
return builder;
}
private static void AddStores(IServiceCollection services, Type userType, Type roleType, Type contextType)
{
var identityUserType = FindGenericBaseType(userType, typeof(IdentityUser));
if (identityUserType == null)
{
throw new InvalidOperationException(Resources.NotIdentityUser);
}
var keyType = identityUserType.GenericTypeArguments[0];
if (roleType != null)
{
var identityRoleType = FindGenericBaseType(roleType, typeof(IdentityRole));
if (identityRoleType == null)
{
throw new InvalidOperationException(Resources.NotIdentityRole);
}
Type userStoreType = null;
Type roleStoreType = null;
var identityContext = FindGenericBaseType(contextType, typeof(IdentityDbContext));
if (identityContext == null)
{
// If its a custom DbContext, we can only add the default POCOs
userStoreType = typeof(UserStore).MakeGenericType(userType, roleType, contextType, keyType);
roleStoreType = typeof(RoleStore).MakeGenericType(roleType, contextType, keyType);
}
else
{
userStoreType = typeof(UserStore).MakeGenericType(userType, roleType, contextType,
identityContext.GenericTypeArguments[2],
identityContext.GenericTypeArguments[3],
identityContext.GenericTypeArguments[4],
identityContext.GenericTypeArguments[5],
identityContext.GenericTypeArguments[7],
identityContext.GenericTypeArguments[6]);
roleStoreType = typeof(RoleStore).MakeGenericType(roleType, contextType,
identityContext.GenericTypeArguments[2],
identityContext.GenericTypeArguments[4],
identityContext.GenericTypeArguments[6]);
}
services.TryAddScoped(typeof(IUserStore).MakeGenericType(userType), userStoreType);
services.TryAddScoped(typeof(IRoleStore).MakeGenericType(roleType), roleStoreType);
}
else
{ // No Roles
Type userStoreType = null;
var identityContext = FindGenericBaseType(contextType, typeof(IdentityUserContext));
if (identityContext == null)
{
// If its a custom DbContext, we can only add the default POCOs
userStoreType = typeof(UserOnlyStore).MakeGenericType(userType, contextType, keyType);
}
else
{
userStoreType = typeof(UserOnlyStore).MakeGenericType(userType, contextType,
identityContext.GenericTypeArguments[1],
identityContext.GenericTypeArguments[2],
identityContext.GenericTypeArguments[3],
identityContext.GenericTypeArguments[4]);
}
services.TryAddScoped(typeof(IUserStore).MakeGenericType(userType), userStoreType);
}
}
Metoda AddEntityFrameworkStores<>() przekierowuje ruch do kolejnej metody, o nazwie AddStores(). Tam natomiast już na samym początku następuje poszukiwanie typu generycznego na który zapisano klasę typu IdentityUser. Jeśli kompilator nie znajdzie takiej instancji w naszym projekcie, zwróci wyjątek i aplikacja się nie skompiluje.
Zatem, całą tę metodę musimy napisać na nowo, jeśli chcemy pozbyć się konieczności odwołania do klasy IdentityUser.
Co jednak robi ta nieszczęsna metoda AddStores()?
Na początku, jak już pisałem, próbuje ona odszukać typ generyczny przypisany do instancji IdentityUser w naszej aplikacji. Upraszczając, chodzi o typ wpisany w nawiasach trójkątnych obok typu klasy, np. IdentityUser<int>, IdentityUser<string> itp. Jest to typ klucza głównego będącego identyfikatorem danego użytkownika. Może to być wartość liczbowa, albo np. nazwa użytkownika czy też tzw. globally unique identifier, w skrócie GUID.
Następnym etapem wykonywania metody AddStores() jest próba odszukania – w analogiczny sposób – typu identyfikatora w roli użytkownika. Tu również wymusza się na programiście, aby skorzystał z narzuconej przez ASP.NET klasy IdentityRole. Podobnie jak IdentityUser, mamy tutaj kilka nadmiarowych pól, które twórcy frameworka próbują „na siłę” wcisnąć do naszej bazy danych, nie pytając się programisty czy tych danych potrzebuje czy nie. Obok oczywistej informacji, jak nazwa roli użytkownika czy też identyfikatora roli (chociaż niektórzy twórcy aplikacji mogliby przyjąć że nazwa roli wystarczy jako jej identyfikator), wymusza się na programiście zapewnienie miejsca w bazie danych na pola takie jak: nazwa znormalizowana roli czy „concurrency stamp” (tego zwrotu nawet nie udało mi się przetłumaczyć na język polski), według opisu ma to być jakaś losowa wartość zmieniana za każdym razem kiedy umieszcza się nową rolę użytkownika w zbiorze wszystkich ról.
Jednak po kolejnym przyjrzeniu się temu, co dzieje się wewnątrz metody AddStores(), zwróciłem uwagę na zupełnie inny element, który okazał się być KLUCZOWYM.
Zauważmy że próbując odnaleźć te typy generyczne, odwołano się do klas o nazwie UserStore i RoleStore:
userStoreType = typeof(UserStore<...>).MakeGenericType(...)
roleStoreType = typeof(RoleStore<...>).MakeGenericType(...)
Warto także zwrócić uwagę na dodanie serwisów do mechanizmu dependency injection, gdzie ma miejsce odwołanie do interfejsów o nazwach IUserStore<> i IRoleStore<>:
services.TryAddScoped(typeof(IUserStore<>).MakeGenericType(userType), userStoreType);
services.TryAddScoped(typeof(IRoleStore<>).MakeGenericType(roleType), roleStoreType);
Wspomniane klasy UserStore<> i RoleStore<> nadal zależą od nieszczęsnego IdentityUser (i analogicznie od IdentityRole), jednak oglądając ich kod źródłowy znacznie bardziej zbliżamy się do rozwiązania naszego problemu.
Spójrzmy najpierw na nagłówki wspomnianych klas:
public class UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim> :
UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
IProtectedUserStore<TUser>
where TUser : IdentityUser<TKey>
where TRole : IdentityRole<TKey>
where TContext : DbContext
where TKey : IEquatable<TKey>
where TUserClaim : IdentityUserClaim<TKey>, new()
where TUserRole : IdentityUserRole<TKey>, new()
where TUserLogin : IdentityUserLogin<TKey>, new()
where TUserToken : IdentityUserToken<TKey>, new()
where TRoleClaim : IdentityRoleClaim<TKey>, new()
...
public class RoleStore<TRole, TContext, TKey, TUserRole, TRoleClaim> :
IQueryableRoleStore<TRole>,
IRoleClaimStore<TRole>
where TRole : IdentityRole<TKey>
where TKey : IEquatable<TKey>
where TContext : DbContext
where TUserRole : IdentityUserRole<TKey>, new()
where TRoleClaim : IdentityRoleClaim<TKey>, new()
...
Dodatkowo, zaprezentuję nagłówek klasy UserStoreBase<>, którą rozszerza wymieniona przed chwilą klasa UserStore<>:
public abstract class UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
IUserLoginStore<TUser>,
IUserClaimStore<TUser>,
IUserPasswordStore<TUser>,
IUserSecurityStampStore<TUser>,
IUserEmailStore<TUser>,
IUserLockoutStore<TUser>,
IUserPhoneNumberStore<TUser>,
IQueryableUserStore<TUser>,
IUserTwoFactorStore<TUser>,
IUserAuthenticationTokenStore<TUser>,
IUserAuthenticatorKeyStore<TUser>,
IUserTwoFactorRecoveryCodeStore<TUser>
where TUser : IdentityUser<TKey>
where TKey : IEquatable<TKey>
where TUserClaim : IdentityUserClaim<TKey>, new()
where TUserLogin : IdentityUserLogin<TKey>, new()
where TUserToken : IdentityUserToken<TKey>, new()
...
Zwróćmy teraz uwagę na fakt, iż zacytowane klasy: UserStore<>, UserStoreBase<> i RoleStore<> implementują interfejsy odpowiadające za przechowywanie różnych informacji na temat użytkownika lub jego roli w systemie. Wszystkie one dziedziczą odpowiednio po IUserStore<> lub IRoleStore<>.
Przypomnę, iż istotą tych poszukiwań było zdobycie wiedzy o tym, co należy zrobić, aby skonfigurować ASP.NET Identity przy użyciu własnego formatu bazy danych, z użyciem tylko tych pól które są nam rzeczywiście potrzebne.
Rozwiązaniem problemu jest samodzielna implementacja interfejsów IUserStore<>, IRoleStore<> lub, w razie potrzeby innego, bardziej szczegółowego interfejsu dziedziczącego po IUserStore<>, a następnie użycie tej implementacji w konfiguracji naszej aplikacji.
Niestety, rozwiązanie to okazało się być obarczone pewną wadą, która wywodzi się z samej konstrukcji ASP.NET Identity i według mojej wiedzy, nie ma możliwości jej idealnego „ominięcia”.
Wśród metod znajdujących się w najbardziej podstawowych interfejsach IUserStore<> i IRoleStore<> znalazło się odniesienie do tzw. „znormalizowanej nazwy” użytkownika lub roli, w postaci metod GetNormalizedUserNameAsync() i SetNormalizedUserNameAsync() dla IUserStore<> i analogicznych dla IRoleStore<>. Jedynym wyjściem z tej sytuacji – jeżeli nie chcemy zamieścić w bazie osobnego pola dla nazwy znormalizowanej – jest kreatywne „obejście” tej kwestii w kodzie. Możemy tu skorzystać z podpowiedzi zamieszczonej przez jednego z użytkowników portalu Stackoverflow.com – zwracanie w metodzie „Get…” naszej nazwy zapisanej wielkimi literami, oraz ignorowanie operacji „Set…” poprzez pozostawienie tej metody pustej (implementacja nie wykonująca żadnej akcji).
Możesz skomentować ten wpis w serwisach społecznościowych: Linkedin (przejdź) lub Facebook (przejdź).