Uwierzytelnianie i Autoryzacja to dwa procesy, bez których ciężko wyobrazić sobie nowoczesną aplikację. Platforma .NET wspomaga programistów dostarczając .NET Identity jako wbudowany element. Już od wersji 8-mej otrzymaliśmy do dyspozycji local identity na poziomie API. Niestety, na nasze nieszczęście, jest uboga w funkcjonalności. Szczególnie jeśli myślimy o stworzeniu systemu enterprise złożonego z licznych serwisów, bazujących na współdzielonych tożsamościach.
Na nasze szczęście świat it nie pozostawia nas z jednym rozwiązaniem. Jedną z opcji jest wykorzystanie protokołu OIDC aby stworzyć centralny serwis zarządzający dostępami, uwierzytelnieniem itd. Kiedyś w ekosystemie .NET popularnym rozwiązaniem był IdentityServer, natomiast, jeżeli dobrze pamiętam, to gdzieś w okolicy roku 2020 twórcy postanowili zrobić je płatnym, co pozostawiło pewną pustkę. Sporo aplikacji zmigrowało się na usługi chmurowe, takie jak Auth0, Okta, AWS Cognito, duża część przerzuciła się na OpenSource takie jak np. Keycloak. Istnieje też grupa, która pozostała na ostatniej wersji open-source IdentityServer-a. No i oczywiście nie można zapomnieć że zawsze możemy przejść na własną implementację używając bibliotek niższego poziomu jak np. OpenIddict.
W dzisiejszym artykule, który będzie dość długi, chciałbym wam przedstawić rozwiązanie open source, które ostatnio przykuło moją uwagę: Zitadel. Jest to o tyle ciekawy kawałek kodu, że można z niego korzystać na podstawie licencji Apache License 2.0
, czyli pod pewnymi warunkami możemy z niego korzystać nawet w komercyjnych projektach. Więcej o tej licencji możecie przeczytać w ich repozytorium na githubie.
Zitadel - Self-Hosted Setup
Zacznijmy może o setup-u lokalnego. Przyda nam się docker, który wraz z docker-compose pozwoli nam na szybkie uruchomienie serwera Zitadel. Dla uproszczenia zebrałem wszystkie potrzebne instrukcje w jeden plik:
services:
zitadel:
networks:
- common
image: 'ghcr.io/zitadel/zitadel:latest'
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled'
environment:
- 'ZITADEL_DATABASE_POSTGRES_HOST=postgres'
- 'ZITADEL_DATABASE_POSTGRES_PORT=5432'
- 'ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel'
- 'ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel'
- 'ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel'
- 'ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable'
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=myuser'
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD=mysecretpassword'
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable'
- 'ZITADEL_EXTERNALSECURE=false'
depends_on:
postgres:
condition: 'service_healthy'
ports:
- '8080:8080'
postgres:
image: postgres:14-alpine
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: mysecretpassword
POSTGRES_DB: mydatabase
POSTGRES_USER: myuser
networks:
- common
healthcheck:
test:
[
"CMD",
"pg_isready",
"--username=myuser",
"--dbname=mydatabase",
"--host=127.0.0.1",
"--port=5432"
]
interval: 2s
timeout: 1m
retries: 5
start_period: 10s
pg-admin:
image: dpage/pgadmin4
ports:
- 5050:80
environment:
PGADMIN_DEFAULT_EMAIL: dbadmin@example.com
PGADMIN_DEFAULT_PASSWORD: letmein
networks:
common:
Podsumowując to co tutaj się tworzy... Oczywiście mamy kontener Zitadel wraz z konfiguracją pozwalającą na połączenie się z bazą PostgreSql. Następnie dzięki sekcji:
depends_on:
postgres:
condition: 'service_healthy'
informujemy docker-a, aby poczekał, aż kontener o nazwie "postgres" będzie gotowy przyjąć ruch. W ten sposób uzyskujemy pewność, że baza danych będzie dostępna podczas podczas inicjalizacji.
Następnie mamy dodaną bazę danych ze zdefiniowanym healthcheck-iem oraz opcjonalny mały kontenerek z aplikacją pg-admin. W razie braku klienta do bazy Zitadel-a chętnych zapraszam na stronę http://localhost:5050
, która umożliwia skonfigurowanie połączenia pgadmin-a z bazą. Niestety instrukcje muszę pominąć, długość tego artykułu i tak zakrawa o kategorie “dla wytrwałych”.
Po uruchomieniu komendy:
docker-compose -f path/to/file/above.yml up
Trzeba poczekać kilka minut i nasze środowisko będzie gotowe do pracy.
Konfiguracja Aplikacji Frontend
No dobra, trochę pojechałem z tytułem. Komu pociekła ślinka na smaczną konfiguracje Next.js - a będzie musiał przełknąć gorycz zawodu. Naszą aplikacją frontend będzie stary, dobry postman. Nie mniej, jeżeli chcielibyście abym zrobił artykuł jak takiego Next-a zintegrować z Zitadel to dajcie znać, chętnie coś takiego upichcę.
No dobrze, mamy infrastrukturę postawioną na nogi, teraz czas skonfigurować... samego Zitadel-a orazi pierwszą aplikację do otrzymania tokeny.
Tak więc zaczynajmy. W pierwszej kolejności, musimy wejść na adres http://localhost:8080
- to właśnie na tym hoście uruchomiliśmy aplikację przez docker-compose.
Po wejściu na stronę powinniśmy ujrzeć widok podobny do tego poniżej:
Zitadel był na tyle miły, że podczas procesu inicjalizacji utworzył pierwszego użytkownika:
username: zitadel-admin@zitadel.localhost
password: Password1!
Po wpisaniu danych logowania będziemy musieli przejść przez dwa procesy:
- Zresetowanie standardowego hasła dla admin-a (niestety nie da się go obejść).
- Konfiguracja uwierzytelnienia 2FA. Jest to krok opcjonalny, ale! O ile teraz działamy na środowisku lokalnym i polecam ominąć ten krok, to na środowisku produkcyjnym, a nawet i testowym, polecałbym skonfigurowanie 2FA.
Konfiguracja Klienta Frontendowego
Po przejściu obu procesów Zitadel powinien nas przenieść na stronę główną, gdzie znajduje się dość sporych rozmiarów przycisk "Create Application".
Po jego wciśnięciu zostaniemy przekierowani do wieloetapowego kreatora aplikacji.
Nie musimy tworzyć nowego projektu. Wystarczy, że skorzystamy z gotowego projektu o nazwieZITADEL
. Na ten moment, jako framework, polecam wybrać other
, dzięki czemu przejdziesz przez cały kreator. Jest to o tyle ważne, że w tym tutorialu będziemy używać postman-a jako frontendu. W przypadku wybrania konkretnego framework-a, wybór możliwych "flow" będzie ograniczony lub całkowicie pominięty.
Następnie zostaniemy przekierowani do kreator-a:
PKCE
i Implicit
. Nie mając preferencji bezpieczniejszym wyborem będzie PKCE
, natomiast nigdy nie wybierajcie Implicit
dla aplikacji typu SPA (więcej możecie o tym przeczytać tutaj ). Jeżeli interesuje was na czym polega flow PKCE to polecam artykuł od Auth0 opisujący cały schemat logowania.
https://oauth.pstmn.io/v1/callback
(kiedyś zamiast callback był browser-callback i wdalszym ciągu, w niektórych tutorialach, możecie się na to natchnąć).
ClientId
, który będziemy mogli wykorzystać w postmanie do pobrania tokenó z Zitadel-a.
Konfiguracja Postman-a
Skoro konfigurację aplikacji frontend-owej mamy już zakończoną to przejdźmy do postmana, abyśmy mogli w automatyczny sposób pobrać tokeny z naszego Identity Provider-a.
Na nasze szczęście nie ma tu czarnej magii. Musimy przejść do zakładki Authorization
i wybrać AuthType
jako OAuth 2.0
. Postman wyświetli nam formularz do skonfigurowania klienta OIDC. Po ustawieniu podstawowych parametrów jak nazwa tokena, prefix header-a, poniżej znajdziemy znacznie bardziej zaawansowaną sekcję.
To co może się rzucić w oczy to konieczność ustawienia adresów URL dla endpointów służących do autoryzacji i pobierania access token-a. Skąd możemy je pobrać?
Na pomoc przychodzi nam specyfikacja OIDC, która mówi, że każdy Identity Provider, który implementuje specyfikację OIDC powinien udostępniać swoją konfigurację pod adresem {HOST}/.well-known/openid-configuration
czy w naszym wypadku: http://localhost:8080/.well-known/openid-configuration
. Nie inaczej jest w przypadku Zitadel-a.
Kiedy wejdziemy pod powyższy adres naszym oczom ukaże się poniższy widok
- Upewnić się, że
Grant type
jest ustawiony naAuthorization Code (With PKCE)
; - Zaznaczyć, że będziemy używali przeglądarki do autoryzacji;
- Ustawić
ClientId
na to, które otrzymaliśmy wcześniej; - Ustawić poprawny scope. To jedyne problematyczne miejsce w tej konfiguracji. Oczywiście nie powinniście mieć problemów z ustawieniem takich standardowych scope-ów jak openid, email czy offline_access (dygresja: pamiętajcie, że aby z niego skorzystać to trzeba skonfigurować Refresh Token w panelu Zitadel-a.). Natomiast jest tutaj jeszcze jeden, dość niestandardowy scope
urn:zitadel:iam:org:project:id:269430187052040195:aud
. Jako że standardowo tokeny są limitowane do projektu zitadel-a to musimy podać ten scope w formacie:urn:zitadel:iam:org:project:id:{ClientId.Replace("@projectname", "")}:aud
Teraz, wystarczy zjechać na sam dół strony w postmanie i nacisnąć duży pomarańczowy przyciskGet New Access Token
. Po chwili powinna się otworzyć przeglądarka internetowa i jeżeli jesteś ciągle zalogowany do konsoli zitadel-a zostaniesz niemal natychmiast przekierowany doRedirect Url
, który został ustawiony wcześniej. W przypadku braku zalogowania zitadel poprosi Cię o podanie nazwy użytkownika i hasła, a dopiero później nastąpi przekierowanie.
Po przejściu całego flow powinieneś otrzymać tokeny w postmanie. Postman sam powinien je zapisać i umożliwić Ci korzystanie z nich.
Konfiguracja Aplikacji Backend-owej
Już naprawdę niewiele pozostało do końca tego tutorialu. Swoją drogą naprawdę gratuluje cierpliwości, ponieważ jest to jeden z dłuższych artykułów na moim blogu. Nie mniej jak mamy już konfigurację dla aplikacji frontendowej, to potrzebujemy drugiej konfiguracji dla aplikacji backendowej. Sam proces nie będzie się jakoś szczególnie różnił od tego przez co przechodziliśmy dla aplikacji frontendowej. To, co jest ważne: musimy ją stworzyć w tym samym projekcie co poprzednio. Inaczej aplikacja .NET nie będzie w stanie zweryfikować tokenów poprzez introspekcję. Jak wygląda samo flow introspekcji możecie przeczytać tutaj: link
W celu ułatwienia sobie życia na następnym ekranie wybierzmyCODE
jako metodę uwierzytelnienia. To pozwoli nam na wygenerowanie dwóch wartości ClientId
i ClientSecret
dla naszej aplikacji.
Zostaną nam zaprezentowane w podobnym popupie co ClientId
po ostatnim kroku kreatora.
W między czasie napotkaliśmy jeszcze krok ustawiający Redirect Url
, natomiast w przypadku tego tutoriala nie ma on większego znaczenia. Chcąc być całkowicie zgodni z specyfikacją OIDC coś powinniśmy tam wpisać.
Do Kodu!
Ostatnia część tego tutorialu, pewnie wyczekiwana przez większość czytelników, czyli to co tygryski lubią najbardziej... kodzik 🙂
Tak więc mając nasze aplikacje skonfigurowane w Zitadel musimy odpowiedzieć sobie na pytanie - w jaki sposób zintegrować aplikację .NET? Na pomoc przychodzi nam biblioteka Zitadel.NET. Oczywiście można również wykorzystać standardowe interfejsy OIDC, które są zaimplementowane w .NET... ale po co sobie utrudniać?
Będąc w terminalu, w lokacji z naszym projektem, musimy wpisać
dotnet add package Zitadel
lub dodać paczkę bezpośrednio do pliku csproj
<PackageReference Include="Zitadel" Version="6.1.2" />
Dzięki temu implementacja jest banalnie prosta.
Krok 1: Zdefiniujmy endpoint, który będzie schowany za warstwą uwierzytelnienia:
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", async (context) =>
{
if (context.User.Identity?.IsAuthenticated == true)
{
await context.Response.WriteAsync($"Hello, {context.User.Identity.IsAuthenticated}!");
}
else
{
await context.Response.WriteAsync("Hello, Guest!");
}
}).RequireAuthorization();
Pamiętaj, aby zarejestrować middlewar-y odpowiedzialne za uwierzytelnienie i autoryzację.
Krok 2: W sekcji odpowiedzialnej za konfiguracje kontenera DI, zarejestrujemy konfigurację uwierzytelnienia wykorzystującą Zitadel
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "ZITADEL_BASIC";
options.DefaultChallengeScheme = "ZITADEL_BASIC";
}).AddZitadelIntrospection("ZITADEL_BASIC", o =>
{
o.Authority = "http://localhost:8080/";
o.ClientId = "269432505394855939@zitadel";
o.ClientSecret = "R4XBPcRUY0cd3T2hcnvUn6dxVYXRUydbYcibH8qwkRuO7IdOWZPBoBX1oTlGiREX";
});
builder.Services.AddAuthorization();
Oczywiście ClientId
i ClientSecret
nie powinny być zahardkodowane, w tym przypadku jest to zrobione czysto dla celów demonstracyjnych.
Gotowe! Dosłownie tyle wystarczy aby zintegrować ten CIAM z naszą aplikacją!
Proste prawda?
Ale to nie wszystko! Wraz z wykorzystaniem biblioteki Zitadel.NET otrzymujemy mega ułatwienie jeśli chodzi o lokalny development.
Bonus - Local Development
Mając “szczęście” pracować z systemem typu IAM / CIAM etc. wiesz jak bardzo może to utrudniać lokalny development.Dla nie wtajemniczonych - bardzo. Generowanie tokenów ze środowiska testowego jest narażone na błędy / downtime etc. Bardzo często spotykałem się z tym, że programiści po prostu komentowali atrybut [Authorize]
na endpoincie, na którym obecnie pracowali. Od czasu do czasu napotykała nas sytuacja, w której znajdowaliśmy taki kwiatek podczas code review:
//[Authorize]
public async Task<IActionResult> SomeAction()
Ratunkiem jest biblioteka Zitadel, która pozwala na dodanie "fejkowego" uwierzytelnienia:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "ZITADEL_FAKE";
options.DefaultChallengeScheme = "ZITADEL_FAKE";
}).AddZitadelFake("ZITADEL_FAKE", o =>
{
o.FakeZitadelId = "1337";
o.AdditionalClaims = new List<Claim>() {
new(ClaimTypes.Name, "Kamil"),
new(ClaimTypes.Email, "test@test.local")
};
});
builder.Services.AddAuthorization();
W takim wypadku wystarczy wysłać żądanie z nagłówkiem Authorize: Bearer 1337
abyśmy mogli się cieszyć z wykorzystania naszej fejkowej tożsamości!
Podsumowanie
Zitadel to jest naprawdę ciekawa opcja do rozważenia kiedy następnym razem będziecie potrzebowali jakiegoś systemu typu IAM / CIAM. Zitadel dostarcza bardzo fajnie zrobiony UI, jest wydajne jak i ma fajną bibliotekę dla świata .NET!
Jak zwykle mam nadzieję że się podobało!
Do Następnego! Cześć 👋