Blog

GraphQL, .Net und React miteinander verbinden – Teil II: Veda Versum backend authentication.

Mikhail Shabanov
Mikhail Shabanov
21. Februar 2022

 

„If you optimize everything, you will always be unhappy“ –  Donald Knuth

Mit einem guten Tool können Sie Aufgaben effektiv erledigen und dabei Spaß haben. Es macht aber doppelt so viel Spaß, das Werkzeug selbst zu entwickeln 🙂

Hallo, hier ist wieder Mikhail von Centigrade. Im Blogartikel möchte ich weiter unser Tool aufbauen – eine Wissensdatenbank namens Veda Versum. Der Artikel ist der zweite der Serie „GraphQL, .Net und React miteinander verbinden“. Im vorherigen Artikel haben wir unsere Zielanwendung, Anforderungen und Architektur definiert. Wir haben .Net 6 für das Backend mit der Hot Chocolate Library für GraphQL API und React als UI-Framework gewählt. Und wir haben das Gerüst für die Backend-Anwendung erstellt. Jetzt ist es an der Zeit, unserem Backend Leben einzuhauchen.

Heute werden wir dem Backend eine Authentifizierung hinzufügen. Wir werden die Datenpersistenz für unsere Anwendung definieren und implementieren, über GraphQL-Resolver und Datenlader sprechen und ein paar Worte über das Testen verlieren.

Let’s go! 😉

1.   Authentifizierung und Autorisierung

Im vorigen Artikel haben wir bereits besprochen, dass jeder Nutzer die Anwendung öffnen, einige Wissenskarten erstellen, Karten lesen und andere Nutzer sehen kann, die gerade online sind. Natürlich soll unsere Veda Versum Anwendung jeden Nutzer persönlich kennen ?Wie Sie bereits wissen, ist die übliche Art, sich in der IT-Welt „zu zeigen“, sich in das System einzuloggen. Ja, um dies zu tun, sollten Sie ein Konto mit Login und Passwort haben. Das ist nichts Neues. Wir müssen die gleiche Funktionalität für unsere brandneue Anwendung bereitstellen. Aber wir werden das Rad nicht neu erfinden und das Verfahren zur Speicherung der Benutzerdaten nicht selbst einführen. Wir werden das Standardprotokoll OAuth 2 verwenden, bei dem davon ausgegangen wird, dass wir bereits vorhandene Authentifizierungsserver für unsere Benutzer verwenden. Viele Anbieter wie Microsoft, Google, Twitter, Facebook und andere unterstützen dieses Protokoll. Wenn Sie also Ihre eigene Benutzerverwaltung implementieren möchten, aber keinen eigenen Authentifizierungsserver betreiben wollen, können Sie diese verwenden. Ein wichtiger Akteur in diesem Bereich ist Auth0, der sich auf Authentifizierungsdienste spezialisiert hat. Aber da unser Team GitLab für die meisten unserer Projekte verwendet, haben wir alle ein Konto bei GitLab. Daher wird GitLab.com unser OAuth-Anbieter sein. Der Veda Versum-Benutzer muss also kein weiteres Konto erstellen, um sich in Veda Versum anzumelden, sondern kann ein bereits bestehendes Konto in GitLab verwenden. Unsere Anwendung leitet den Benutzer auf die Anmeldeseite von GitLab um. Nachdem der Benutzer seinen Benutzernamen und sein Kennwort eingegeben hat, wird er mit einem Authentifizierungs-Token zu unserer Anwendung zurückgeleitet. Und unsere Anwendung funktioniert nur mit diesem Token.

Soweit unsere Anwendung Frontend, Backend und GitLab als OAuth 2-Provider hat, wird das Authentifizierungsschema wie folgt aussehen:

Authentication scheme

Abb. 1: Authentifizierungsschema

Das ganze Schema sieht ein bisschen kompliziert aus. Ich werde versuchen, alle Details zu erklären und Sie Schritt für Schritt durch alle Prozesse zu führen.

1.1 Mutation der GitLab-Authentifizierung

Zuallererst müssen wir OAuth auf der Einstellungsseite von GitLab einrichten. Folgt dieser Anleitung, um eine gruppeneigene Anwendung zu erstellen. Als Ergebnis erhalten wir eine Anwendungs-ID und ein Geheimnis. Wir benötigen sie für die nächsten Schritte.

Dies ist der Link zur Gitlab OAuth API zur Implementierung der Authentifizierung. Um die Schritte 2 und 4 aus Abbildung 1 zu implementieren, müssen wir die GitLab-API-Methode oauth/authorize aufrufen. Diese Methode wird von der Benutzeroberfläche aus aufgerufen. Später müssen wir für die Schritte 6 und 7 die Methode oauth/token aufrufen. Und für die Schritte 8 und 9 – die Methode /api/v4/user aufrufen.

Das Frontend ist nicht unser heutiges Ziel, hier werden wir uns auf den Backend-Teil konzentrieren. Um die Schritte 5 bis 10 aus Abbildung 1 zu implementieren, müssen wir eine brandneue Mutation in unserer GraphQL-API erstellen, die GiltLabUser-Informationen abrufen soll. Sie nimmt den GitLab Single-Use Auth Code als Parameter und gibt ein gesichertes JWT-Token zurück, das die Benutzerinformationen enthält. Diese Mutation wird die Implementierung der Schritte 6 – 9 aus Abbildung 1 beinhalten.

Um das zu erreichen, müssen wir unser Projekt so ändern:

  • Definieren Sie Parameter4 in json;
  • Erstellen Sie die Klasse GitLabOauthSettings, die die Werte dieser Parameter4 lesen und speichern wird;
  • Definieren Sie die Schnittstelle IGitlabOauthService mit 2 Methoden darin;
  • Definieren Sie die Klasse GitLabOauthService, die die Schnittstelle implementiert und die Oauth-App-Einstellungen und den GitLab-Http-Client als Dependency-Injection-Objekte übernimmt;
  • Definieren Sie eine neue Mutation mit der Methode GitLabAuthenticate. Diese Mutation wird IGitlabOauthService als Dependency Injection Objekt verwenden;
  • Konfiguration, http-Client, GitLabOauthService und Mutation in cs einrichten.

Die Codebasis sollte wie folgt aussehen:

GitLab authentication mutation class diagram

Abb. 2: GitLab authentication mutation class diagram

Also, wie Linus Torvalds sagte: “Talk is cheap, show me the code”.

 

appsettings.json:
  "GitLabOauth": {
    "BaseAddress": "https://gitlab.example.com",
    "ClientId": "FakeClientId",
    "Secret": "FakeSecret",
    "JwtSecret": "FakeJwtSecret"
  },

Ein Tipp zu den App-Einstellungen. Ich verwende den app secrets-Mechanismus, um sensible Daten in der Entwicklungsmaschine statt in der appsettings.json zu speichern. Auf diese Weise kann ich den Code mit wirklich sensiblen Daten immer debuggen und sie gleichzeitig nie an das öffentliche Repository übergeben.

OAuthMutation.cs:
        /// <summary>
        /// This method accepts GitLab oauth code, and returns JWT token with GutLab user as claim
        /// </summary>
        /// <param name="oauthCode">OAuth code can be generated by this URL https://gitlab.example.com/oauth/authorize</param>
        /// <remarks>
        /// More info about GitLab OAuth https://docs.gitlab.com/ee/api/oauth2.html
        /// </remarks>
        public async Task<string> GitLabAuthenticate(string oauthCode)
        {
            try
            {
                var user = await _oauthService.GetUser(oauthCode);
                if (user == null)
                {
                    throw new ApplicationException($"Can not find GitLab user by code {oauthCode}");
                }
                return _oauthService.GenerateToken(user);
            }
            catch (Exception e)
            {
                _logger.LogError(e,"Authentication fails");
                throw;
            }
        }

Die gesamte Mutationsklasse kann man im Repository finden. Hier ist die Mutationsmethode, die nur zwei Servicemethoden aufruft – get user und generate JWT token

GitLabOauthService.cs

        /// <inheritdoc />
        public string GenerateToken(User user)
        {
            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSecret));
            var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
            var serializedUser = JsonSerializer.Serialize(user);

            // generate jwt token
            var claims = new[]
            {
                new Claim(ClaimTypes.Name, user.Name?? "unknown"),
                new Claim(ClaimTypes.Email, user.Email?? "unknown"),
                new Claim(ClaimTypes.UserData, serializedUser)
            };

            var token = new JwtSecurityToken(
                issuer: JwtIssuer,
                audience: JwtIssuer,
                claims: claims,
                expires: DateTime.Now.AddDays(30), // Token is valid for 1 month. Don't use this parameter in production!
                signingCredentials: credentials);

            return new JwtSecurityTokenHandler().WriteToken(token);
        }

        /// <inheritdoc />
        public async Task<User?> GetUser(string oAuthCode)
        {
            var token = await GetAccessToken(oAuthCode);
            return await GetGitLabUser(token);
        }

        private async Task<string> GetAccessToken(string oAuthCode)
        {
            var parameters = new Dictionary<string, string>
            {
                { "client_id", _settings.ClientId }, 
                { "client_secret", _settings.Secret },
                { "code", oAuthCode },
                { "grant_type", "authorization_code" },
                { "redirect_uri", "https://localhost:5001" }
            };
            var encodedContent = new FormUrlEncodedContent(parameters!);

            var client = _httpClientFactory.CreateClient(GitLabHttpClientName);

            var url = $"{_settings.BaseAddress}/oauth/token";

            var response = await client.PostAsync(url, encodedContent).ConfigureAwait(false);
            response.EnsureSuccessStatusCode();

            await using var responseStream = await response.Content.ReadAsStreamAsync();
            var authTokenResponse = await JsonSerializer.DeserializeAsync<OAuthTokenResponse?>(responseStream);

            if(string.IsNullOrEmpty(authTokenResponse?.AccessToken))
            {
                throw new ApplicationException($"Can not get access_token from url '{_settings.BaseAddress}' and Auth code '{oAuthCode}'");
            }
            return authTokenResponse.AccessToken;
        }

        private async Task<User?> GetGitLabUser(string token)
        {
            string url = $"{_settings.BaseAddress}/api/v4/user";

            var client = _httpClientFactory.CreateClient(GitLabHttpClientName);
            client.DefaultRequestHeaders.Authorization
                = new AuthenticationHeaderValue("Bearer", token);
            client.DefaultRequestHeaders.Add("User-Agent", "VedaVersum");

            var response = await client.GetAsync(url);

            response.EnsureSuccessStatusCode();
            
            await using var responseStream = await response.Content.ReadAsStreamAsync();
            return await JsonSerializer.DeserializeAsync<User?>(responseStream);
        }

Die gesamte Serviceklasse findet ihr im Repository. Hier sind drei Hauptmethoden:

Die erste Methode ruft die GitLab-API auf, um ein Zugriffstoken zu erhalten. Dieses Token benötigen wir in der zweiten Methode, um den GitLab-Benutzer von der GitLab-API abzurufen. Und die dritte Methode generiert ein JWT-Token mit Benutzerinformationen, die durch den SHA-Algorithmus mit einem geheimen Schlüssel gesichert sind, der in den Anwendungseinstellungen gespeichert ist.

startup.cs:

        public void ConfigureServices(IServiceCollection services)
        {
            // GitLab authorization configuration
            var gitLabOauthConfig = new GitLabOauthSettings();
            Configuration.GetSection("GitLabOauth").Bind(gitLabOauthConfig);
            services.AddSingleton(gitLabOauthConfig);

            // GitLab authorization
            services.AddHttpClient(GitLabOauthService.GitLabHttpClientName);
            services.AddTransient<IGitLabOauthService, GitLabOauthService>();
            services
                .AddGraphQLServer()
                .AddInMemorySubscriptions()
                .AddQueryType<VedaVersumQuery>()
                    .AddType<VedaVersumCardObjectType>()
                .AddMutationType(d => d.Name("Mutation"))
                    .AddType<OAuthMutation>() // Register new OAuth Mutation in the GraphQL API
                    .AddType<VedaVersumMutation>()
                .AddSubscriptionType<VedaVersumSubscription>();
       }

Hier lesen wir die Konfiguration aus den App-Einstellungen und fügen die Einstellungen zum DI-Container hinzu. Wir pushen einen neuen GitLabOauthService in den DI-Container und registrieren eine neue OAuthMutation in der GraphQL-API

Jetzt ist unser Dienst bereit, JWT-Tokens zu erzeugen. In Abbildung 1 sehen Sie, dass wir noch irgendwo den GitLab-Authentifizierungscode für den einmaligen Gebrauch abrufen müssen. Dies sind die Schritte 2, 3 und 4, die in Zukunft von Frontend verwaltet werden. Da wir aber noch kein Frontend haben, werden wir diese Schritte manuell durchführen. Wir sollten den Browser öffnen und diese Url an GitLab senden:

https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=https://localhost:5001&response_type=code&state=TEMPORARY_STATE&scope=read_user

Die 3 Parameter sollte man ersetzen:

  • example.com für die URL Ihres Gitlab-Servers
  • APP_ID auf Ihre eigene Anwendungs-ID, die Sie beim Erstellen der gruppeneigenen GitLab-Anwendung erhalten haben
  • TEMPORARY_STATE kann eine beliebige Zeichenfolge sein. Ich verwende einen GUID-Generator, um ihn zu erzeugen.

Wenn Sie zu dieser URL navigieren, werden Sie zur GitLab-Authentifizierungsseite weitergeleitet. Und nach erfolgreicher Anmeldung werden Sie zu https://localhost:5001 weitergeleitet, das noch nicht existiert. Alles, was Sie im Moment brauchen, steht in der Adresszeile des Browsers:

https://localhost:5001/?code=4debdf62d4458dbd02be661661506c869582232b0bf7632d5e10e74045318eb0&state=29aa0c42-58d0-4129-9d76-33094fa56401

Wir werden diesen Code in unserer Authentifizierungsmutation verwenden. Wenn Sie die VedaVersum-Anwendung starten, wird die GraphQL BananaCakePop IDE angezeigt, in der Sie die Mutation aufrufen können:

Authentication mutation execution
Abb. 3: Ausführung der Authentifizierungsmutation

Wenn Sie alles richtig gemacht haben, wird die Mutation das JWT-Token zurückgeben. Sie können dieses Token mit https://jwt.io/ testen.

 

JWT token
Abb 4. JWT token

Das bedeutet, dass wir das richtige Token haben. In Zukunft wird dieses Token irgendwo im Frontend gespeichert werden. Und jedes Mal, wenn das Frontend Abfragen oder Mutationen in unserer GraphQL-API aufruft, sollte es dieses Token als http-Header zu jeder Anfrage hinzufügen, um den Benutzer zu authentifizieren. Da wir aber noch kein Frontend haben, sollten wir dieses Token irgendwo im Notizblock aufbewahren. Wir werden es im nächsten Kapitel brauchen.

1.2 Autorisierung durch JWT-Token

Wir haben bereits einen Mechanismus zur Authentifizierung von Benutzern und zur Speicherung von Benutzerinformationen als verschlüsselte JWT-Token am Frontend geschaffen. So weit, so gut. Jetzt ist es an der Zeit, unsere GraphQL-API zu sichern und nur den Nutzern mit gültigen JWT-Tokens die Nutzung ihrer Abfragen und Mutationen zu erlauben.

Jede HTTP-Anfrage in Asp.net Core Server durchläuft eine „Pipeline“. Diese Pipeline hat ihren eigenen Kontext. Und Entwickler können verschiedene „Middleware“-Typen in diese Pipeline einfügen und den Kontext manipulieren. Asp.net Core verfügt über Standard-Autorisierungsmechanismen, die ebenfalls als Middleware implementiert sind. Wir haben auch den GraphQL-Server von ChilliCream als Middleware hinzugefügt, der für jede Anfrage einen eigenen Kontext hat.

Asp.net core pipeline
Abb. 5. Asp.net core pipeline

Erstens müssen wir unsere JWT-Autorisierungslogik in die Standard-Authentifizierungs-Middleware integrieren. Zweitens müssen wir User zum GraphQL-Middleware-Kontext hinzufügen.

Um die JWT-Token-Authentifizierung als Standard-Asp.net-Core-Middleware hinzuzufügen, müssen wir die Nuget-Pakete „Microsoft.AspNetCore.Authentication.JwtBearer“ und „System.IdentityModel.Tokens.Jwt“ zu unserem Projekt hinzufügen. Und dann richten Sie den Authentifizierungsdienst im DI-Container wie folgt ein:

            // Token validation
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuer = true,
                        ValidateAudience = true,
                        ValidateLifetime = true,
                        ValidateIssuerSigningKey = true,
                        ValidIssuer = GitLabOauthService.JwtIssuer,
                        ValidAudience = GitLabOauthService.JwtIssuer,
                        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(gitLabOauthConfig.JwtSecret))
                    };
                });

Fügen Sie die Middleware für Authentifizierung und Autorisierung in der Methode Configure hinzu:

            app
                .UseWebSockets()
                .UseRouting()
                .UseAuthentication() // Authentication middleware
                .UseAuthorization() // Authorization middleware
                .UseEndpoints(endpoints =>
                {
                    endpoints.MapGraphQL();
                });

Nun, wir haben gerade die Blöcke 1 und 2 aus Abbildung 5 implementiert.

Um die Autorisierung zur GraphQL Server Middleware hinzuzufügen, müssen wir das neue Nuget-Paket „HotChocolate.AspNetCore.Authorization“ hinzufügen. Und um die Autorisierung zum GraphQL DI Setup in startup.cs hinzuzufügen:

        services
            .AddGraphQLServer()
	…
            .AddAuthorization()

Danach müssen wir das Attribut [Authorize] zu den Klassen VedaVersumQuery, VedaVersumMutation und VedaVersumSubscription hinzufügen:

Add [Authorize] attribute to the VedaVersumQuery

Hier haben wir Block Nummer 3 aus Abbildung 5 implementiert.

Jetzt können wir versuchen, eine beliebige Abfrage oder Mutation ohne Autorisierungstoken aufzurufen:

Authentication error for anonymous users
Abb. 6: Authentifizierungsfehler für anonyme Benutzer

Wie Sie sehen können, können anonyme Benutzer unsere GraphQL API nicht mehr nutzen.

Fügen wir jedoch den http-Header {„Authorization“: „Bearer …“} mit einem gültigen Token zu unserer Anfrage hinzufügen, wird die Mutation wie erwartet ausgeführt:

Normal mutation execution for authorized users
Abb. 7: Normale Mutationsausführung für autorisierte Benutzer

Zu guter Letzt: Wenn der Benutzer autorisiert ist, wird das User-Objekt vom Typ PrincipalIdentity zum asp.net Pipeline-Kontext hinzugefügt. Und dieses Objekt wird in allen Middlewares nach der „Autorisierungs“-Middleware zugänglich sein, einschließlich des GraphQL Resolvers im Block (Abbildung 5). Aber in der Geschäftslogik müssen wir das GitLabUser-Objekt mit allen angeforderten Eigenschaften bedienen. Wir können diese PrincipalIdentity direkt im Resolver in GitLabUser umwandeln. Aber wenn wir viele Resolver haben, müssen wir diese Umwandlung jedes Mal vornehmen. Besser wäre es, PrincipalIdentity zu einem früheren Zeitpunkt in der Pipeline in GitLabUser umzuwandeln und das umgewandelte GitLabUser-Objekt in den Kontext der Pipeline zu stellen. Hier ein Beispiel, wie dies auf der Ebene von Block 3 (aus Abbildung 5) geschehen kann:

        services
            .AddGraphQLServer()
	…
            .AddAuthorization()
            .AddHttpRequestInterceptor(
                (context, executor, builder, ct) =>
                {
                    // Deserializing GitLab user from JWT token data
                    if(context.User != null)
                    {
                        var serializedUser = context.User.Claims.Where(c => c.Type == ClaimTypes.UserData)
                            .Select(c => c.Value).SingleOrDefault();
                        if(!string.IsNullOrEmpty(serializedUser))
                        {
                            var user = JsonSerializer.Deserialize<User>(serializedUser);
                            builder.SetProperty("GitLabUser", user);
                        }
                    }
                    return ValueTask.CompletedTask;
                })
            .ModifyRequestOptions(opt => opt.IncludeExceptionDetails = true);

Dieser Interceptor holt sich GitlabUser von PrincipalIdentity und setzt dieses Objekt in den Kontext mit dem Schlüssel „GitLabUser„. Später können wir dieses Objekt in jedem unserer Resolver wie folgt verwenden:

interceptor gets GitlabUser from PrincipalIdentity

OK, wir haben die vollständige Authentifizierung und Autorisierung zu unserem Backend hinzugefügt, indem wir GitLab als OAuth 2-Authentifizierungsanbieter verwenden. Außerdem haben wir unsere GraphQL-API vollständig abgesichert.

Und nicht zuletzt haben wir einen der kompliziertesten Teile des Programms erstellt und die einzige Möglichkeit, die Leistungsfähigkeit zu testen, die Anwendung zu starten und alle notwendigen Schritte manuell auszuführen. Aber manchmal müssen wir schnell überprüfen, ob alles funktioniert. Und noch besser ist es, nach jedem Commit zu testen, ob wir nichts kaputt gemacht haben. Ich habe ein Testprojekt erstellt, das das NUnit-Testframework und die Moq-Bibliothek zur Erstellung von Mock-Objekten verwendet. Es gibt ein Testszenario, in dem ich die .Net-HTTP-Client-Klasse mocke und prüfe, ob die Methoden der Gitlab-Web-API mit den entsprechenden URIs, Parametern und Autorisierungs-Tokens aufgerufen werden. Sie können sich den Code im GitHub-Repository ansehen. Wir werden in unserem nächsten Artikel ausführlich über Unit-Tests sprechen.

Das war’s für heute! 🙂 Im nächsten Artikel werden wir uns mit der Datenpersistenz und GraphQL-Resolvern und Datenladern beschäftigen. Bis Bald! 🙂

 

Möchten Sie mehr zu unseren Leistungen, Produkten oder zu unserem UX-Prozess erfahren?
Wir sind gespannt auf Ihre Anfrage.

Senior UX Manager
+49 681 959 3110

Bitte bestätigen Sie vor dem Versand Ihrer Anfrage über die obige Checkbox, dass wir Sie kontaktieren dürfen.