Säkra ditt API mha JWT

Den här posten visar hur man kan säkra upp sitt web api (ASP.NET Web Api 2 och ASP.NET Core) mha JWT, JSON Web Token.

All kod finns här https://github.com/HeadlightAB/SecuringYourApis.

Vi ska göra följande:

  • Förstå vad JWT är och tekniken kan användas i ett behörighetskontrollflöde.

  • Implementera en token server, Id-server, mha IdentityServer, för att kunna utfärda tokens.

  • Implementera ett ASP.NET WebApi 2 som konsumerar token som en del i dess behörighetskontroll av inkommande anrop, genom att implementera en minimal OWIN-pipeline.

  • Implementera ett ASP.NET Core WebApi som konsumerar token som en del i dess behörighetskontroll av inkommande anrop. Här använder vi oss av det "nya sättet", dvs att definiera policies för behörighetskontrollen att luta sig på.

Länkar i posten

Låt oss rulla igång!

Vad är Jason Web Token/JWT

JWT beskrivs väldigt bra på Wikipedia, https://en.wikipedia.org/wiki/JSON_Web_Token, både vad det är, dess struktur och användningsområden, så det behöver inte göras igen.

Om man funderar en eller ett par rundor över hur man kan och ska använda en JWT så tycker jag att dess enkelhet och öppenhet är otroligt elegant. Man ska aldrig transportera känsliga uppgifter i en JWT, men vill man filosofera lite kring vad som är en känslig uppgift så är till stor del upp till mottagaren av datat.

Det här innebär att informationen i JWTn såklart är avgörande för mottagande system, men det är enbart dess korrekthet som kan verifieras med hjälp av signaturen som JWTn bär med sig och egentligen inte vem som gör själva anropet. Skulle en token hamna på villovägar, till exempel i samband med en man-in-the-middle-attack, så är det en allvarlig händelse. Så länge som en giltig token är i orätta händer så kan denna användas för att komma åt eventuellt känsliga uppgifter i målsystemet. Det finns lite olika åtgärder man kan göra för att minimera skadan. Dessa beskrivs bra här https://developer.okta.com/blog/2018/06/20/what-happens-if-your-jwt-is-stolen.

Flöde vid access

Flödet i exemplet som implementeras innehåller tre aktörer:

  • En klient som önskar åtkomst till resurs
  • En server som håller identiteter och utfärdar tokens
  • En resurs/applikationsserver

JWT flow

Flödet är väldefinierat, beskrivet kort här:

  1. En klient begär en token från Id-servern. Id-servern autentiserar inkommande begäran.

  2. Id-servern svarar, efter lyckad autentisering, med en token till klienten.

  3. Klienten begär åtkomst till en skyddad resurs på applikationsservern, skickar med token i anropet.

  4. Applikationsservern validerar tokens äkthet och returnerar resursen till klienten om denne har rättigheter till den.

Den uppmärksamme noterar ett 'a' och ett 'b' i bilden också. Dessa båda pilar har två betydelser:

  • a: Applikationsservern begär av Id-servern tillräckligt med information för att kunna validera inkommande token.
  • b: Id-servern svarar med begärd info, privat nyckel eller motsvarande, som applikationsservern behöver för att validera tokens som kommer från klienter.

Valideringen av inkommande token, från klient till applikationsserver, kan göras helt utan Id-serverns inblandning men man kan även låta någon annan validera den. I det fallet består token från Id-servern av en referens istället för den verkliga token. I IdentityServer ingår denna funktion och detta ger då följande betydelse hos 'a' och 'b':

  • a: Applikationsservern skickar token-referensen till Id-servern för validering.
  • b: Id-servern returnerar resultatet tillbaka tillsammans med claims och annat i token.

Den senare varianten försämrar såklart prestandan hos applikationen men öka till viss del säkerheten eftersom man på ett mycket snabbare sätt kan invalidera tokens om dessa har kommit på villovägar.

Implementation IdentityServer

Id-servern, token-utfärdaren, implemeneterar vi här med hjälp av IdentityServer. För att förstå exempelimplementationen måste vi förstå minst två begrepp, dels Clients och dels ApiResource. IdentityServer installeras i projektet via ett nuget-paket:

install-package IdentityServer4  

I nuget-paketet finns alla klasser och middleware för request-response-pipeline för en ASP.NET Core Web Application.

Client

En klient i IdentityServer är en aktör som vill få åtkomst till en resurs. Klienten hämtar en token, genom att skicka nödvändiga credentials till IdentityServer. En Client kan i kod se ut enligt:

new Client  
{
    AllowedScopes = new[] {"public.api.write"},
    ClientId = "observerX",
    ClientSecrets = new[] {new Secret("secret.password.for.observer".Sha256())},
    AllowedGrantTypes = GrantTypes.ClientCredentials
}

Ett par kommentarer angående ovan:

  • Det här är en InMemory Client, se kodexemplet nedan. Det här exemplet ska bara belysa dom mest grundläggande egenskaperna hos en Client.

  • AllowedScopes kommer att generera claims i token för klienten och kan användas på resurssidan för auktorisering.

  • Lösenordet är här i klartext, men lagras med fördel hashat i fallet då ett riktigt datalager med klienter används.

ApiResource

En ApiResource är precis som namnet antyder en resurs som exponeras genom ett api. Även i det här fallet belyses dom mest grundläggande egenskaperna i ett kodexempel för en InMemory ApiResource:

new ApiResource("public.api")  
{
    Scopes = new[]
    {
        new Scope("public.api.read"),
        new Scope("public.api.write")
    }
}
  • En ApiResource har ett eller flera scopes kopplade till sig.

  • I det här fallet kopplas klienten ovan till api:et genom scopet "public.api.write".

  • En klient efterfrågar alltid token för alla sina scopes eller för ett specifikt scope.

Konfiguration IdentityServer

När dom två begreppen beskrivna ovan har fallit på plats så är det dags att konfigurera IdentityServer.

För att göra det enkelt att utforska IdentityServer så finns det, som tidigare antytts, en InMemory datastorage-konfiguration för Clients och ApiResources mm. Detta syns i kodexemplet nedan, där klienterna och api-resurserna ligger definierad som statiska egenskaper i klassen IdStore, som återfinns i sin helhet här https://github.com/HeadlightAB/SecuringYourApis/blob/master/IdSrv/IdStore.cs:

public class IdStore  
{
    public static Client[] Clients { get; } = {
        new Client
        {
            AllowedScopes = new[] {"public.api.read"},
            ClientId = "weatherTV",
            ClientSecrets = new[] {new Secret("secret.password.for.weathertv".Sha256())},
            AllowedGrantTypes = GrantTypes.ClientCredentials
        },
        new Client
        {
            AllowedScopes = new[] {"public.api.write"},
            ClientId = "observerX",
            ClientSecrets = new[] {new Secret("secret.password.for.observer".Sha256())},
            AllowedGrantTypes = GrantTypes.ClientCredentials
        }
    };

    public static ApiResource[] ApiResources { get; } =
    {
        new ApiResource("public.api")
        {
            Scopes = new[]
            {
                new Scope("public.api.read"),
                new Scope("public.api.write"),
            }
        }
    };
}

Nedan syns uppsättningen av webbapplikationens services och request-response-pipeline, i klassen Startup https://github.com/HeadlightAB/SecuringYourApis/blob/master/IdSrv/Startup.cs:

public class Startup  
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddIdentityServer(options => { options.IssuerUri = "https://idsv.weatherportal"; })
            .AddDeveloperSigningCredential()
            .AddInMemoryClients(IdStore.Clients)
            .AddInMemoryApiResources(IdStore.ApiResources);
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseIdentityServer();

        app.Run(async (context) =>
        {
            await context.Response.WriteAsync(
               "You have reached the IdSrv...");
        });
    }
}

Notera IssuerUri som är den uri som Id-servern kommer att identifiera sig som samt lägga med i genererad token. Den här uri:n har alltså inget att göra med på vilken uri som IdentityServern är nåbar.

Värt att notera här är att exemplet ovan använder sig av ett DeveloperSigningCredential-cert/key. Detta är såklart inte avsett för produktion utan enbart för utveckling och utforskning av sin IdentityServer. Det genereras en tempkey.rsa-fil i roten på projektet, innehållande information om privat nyckel etc.

Testa implementationen

Testning om implementationen och konfigurationen av IdentityServer fungerar görs enklast via t.ex. Postman eller curl. Prova att anropa Id-servern genom HTTP POST till https://localhost:5001/connect/token, med en body med content-type: application/x-www-form-urlencoded och innehållet 'grant_type=client_credentials&client_id=weatherTV&client_secret=secret.password.for.weathertv'.

Ett anrop med curl skulle kunna se ut så här:

curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=client_credentials&client_id=weatherTV&client_secret=secret.password.for.weathertv" https://localhost:5001/connect/token  

Svaret skulle kunna se ut så här:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ij...[klippt]",
  "expires_in": 3600,
  "token_type": "Bearer",
  "scope": "public.api.read"
}

Datat i access_token är själva token och är det data som ska skickas med i Authorize-headern i api-anropen till applikationsservern.

Nu är vi nog mogna att börja konsumera IdentityServer, både som klient och som api-resurs.

Implementation ASP.NET Web API 2

Det finns en uppsjö nuget-paket för att läsa av inkommande request och "packa upp" den token som kommer med. Det enklaste och mest logiska är att använda sig av en OWIN-pipeline. Det beskrivs alldeles lysande här, kort och koncist i Section 2, https://bitoftech.net/2014/10/27/json-web-token-asp-net-web-api-2-jwt-owin-authorization-server/.

Här, https://github.com/HeadlightAB/SecuringYourApis/tree/master/FullFrameworkPublicMetarApi, finns även ett exempelprojekt som använder nuget-paketet

install-package IdentityServer3.AccessTokenValidation

en konfigurerad OWIN-request-response-pipeline med hjälp av nuget-paketen

install-package Owin  
install-package Microsoft.Owin  
install-package Microsoft.Owin.Host.SystemWeb  

och en route på ValuesController med Authorize-attributet

public class ValuesController : ApiController  
{
    public IEnumerable<string> Get()
    {
        return new string[] { "value1", "value2" };
    }

    [Authorize]
    public string Get(int id)
    {
        return "value";
    }
}

I övrigt är det en helt nyskapad ASP.NET WebApi 2-webbapplikation (.NET 4.7.2).

Implementation ASP.NET Core

När det kommer till .NET Core så finns det redan en inbyggd request-response-pipeline, man behöver alltså inte installera/konfigurera en OWIN-pipe utanpå vanliga ASP.NET Core WebApi.

Det finns dock en mer grundläggande skillnad mellan full framework ASP.NET WebApi och ASP.NET Core WebApi. I ASP.NET Core WebApi har man infört ett nytt begrepp, nämligen Policies. Detta medför att man konfigurerar behörighetsnivåer i samband med att man konfigurerar request-response-pipeline och att man i sitt Authorize-attribut deklarerar vilken policy som gäller för just den resursen man vill skydda.

Läs mer om policies här https://docs.microsoft.com/en-us/aspnet/core/security/authorization/policies

Nedan följer uppsättningen av request-response-pipeline, den kompletta källkoden för projektet återfinns här https://github.com/HeadlightAB/SecuringYourApis/tree/master/PublicMetarApi.

...
public void ConfigureServices(IServiceCollection services)  
{
    services
        .AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
        .AddIdentityServerAuthentication(options =>
        {
            options.ApiName = "public.api";
            options.Authority = "https://localhost:5001";
        });

    services.AddAuthorization(options =>
    {
        options.AddPolicy("public.api.read",
            builder => builder.RequireClaim("scope", "public.api.read"));
    });
    ...
}
...

Det första som händer här är att man konfigurerar api:et att använda en IdentityServerAuthentication. Detta göra man via nuget-paketet IdentityServer4.AccessTokenValidation:

install-package IdentityServer4.AccessTokenValidation  

Man pekar ut vilken ApiResource man kommer från och var Id-servern finns att anropa.

Efter det lägger man till en policy med namnet "public.api.read" som innebär att identiteten som gör anropet måste ha claim "scope": "public.api.read". Denna claim kommer från api-resursens definierade scopes i IdentityServer-implementationen ovan.

Om och endast om inkommande token innehåller den rätta claimen kommer anropet att släppas igenom och resursen blir åtkomlig för klienten.

Resursen skyddas genom Authorize-attributet enligt nedan, där man talar om vilken policy som krävs:

public class TafController : ControllerBase  
{
    [HttpGet("{icao}")]
    [Authorize("public.api.read")]
    public ActionResult<string> Get(string icao)
    {
        return "ESSA 201130Z...";
    }   
    ...
}

I ASP.NET Core WebApi finns alltså inte möjligheten att på ett enkelt sätt överrida Authorize-attributet på samma sätt som i full framework ASP.NET WebApi 2.

Man kan använda sig av ActionFilters, men då krävs det att man bland annat returnerar rätt HTTP Status-kod i dom fallen som man inte tillåter inkommande anrop, vilket man får per automatik av policy-konstruktionen.

Ett exempel på ActionFilter i ASP.NET Core WebApi återfinns i klasserna ScopeRequiredAttribute och WriteScopeRequiredAttribute:

public abstract class ScopeRequiredAttribute : ActionFilterAttribute  
{
    private readonly string _requiredScope;

    protected ScopeRequiredAttribute(string requiredScope)
    {
        _requiredScope = requiredScope;
    }

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (context.HttpContext.User.Identity.IsAuthenticated &&
            context.HttpContext.User.HasClaim("scope", _requiredScope))
        {
            base.OnActionExecuting(context);
        }
        else
        {
            context.Result = new UnauthorizedResult();
        }
    }
}

public class WriteScopeRequiredAttribute : ScopeRequiredAttribute  
{
    public WriteScopeRequiredAttribute() : base("public.api.write")
    {}
}

Användningen av WriteScopeRequired syns nedan, i klassen TafController här https://github.com/HeadlightAB/SecuringYourApis/blob/master/PublicMetarApi/Controllers/TafController.cs:

[HttpPost]
[WriteScopeRequired]
public ActionResult Post()  
{
    return Ok();
}

Testa hela flödet

Nu är ett bra läge att testa hela flödet. Börja med att ha hela den kompletta lösningen tillgänglig, antingen öppen i Visual Studio eller via en kommandotolk startad för att kunna motionera dom ingående projekten. Gör sedan följande:

  1. Starta IdSrv-projektet
  2. Starta ett av api:erna, t.ex. PublicMetarApi
  3. Gör ett anrop till api:et, https://localhost:6001/api/metar/ => lyckas 200 OK
  4. Gör ett anrop till api:et, https://localhost:6001/api/taf/ESSA => misslyckas 401 Unauthorized
  5. Anropa IdSrv på https://localhost:5001 mha curl eller Postman för att hämta hem en token
  6. Kopiera token och skicka med den i ett anrop till https://localhost:6001/api/taf/ESSA => lyckas 200 OK

Punkt 4 och 6 kräver kommentar. Anropen till api:et utan och med en token mha curl skulle kunna se ut så här:

\> curl https://localhost:6001/api/taf/ESSA -verbose
...
* schannel: decrypted data buffer: offset 0 length 102400
< HTTP/1.1 401 Unauthorized  
< Date: Mon, 09 Sep 2019 05:47:45 GMT  
< Server: Kestrel  
< Content-Length: 0  
< WWW-Authenticate: Bearer  
...
\> curl https://localhost:6001/api/taf/ESSA -H "Authorization: Bearer eyJhbGciOiJSUz... [klippt]" -verbose
...
* schannel: decrypted data buffer: offset 0 length 102400
< HTTP/1.1 200 OK  
< Date: Mon, 09 Sep 2019 05:58:10 GMT  
< Content-Type: text/plain; charset=utf-8  
< Server: Kestrel  
< Transfer-Encoding: chunked  
<  
ESSA 201130Z 2012/2112 ...  
...

Se vidare README.md här https://github.com/HeadlightAB/SecuringYourApis/blob/master/README.md

För den intresserade och nyfikne så kan .NET Core-applikationerna i lösningen rakt av köras som docker-containers. Det är extremt enkelt numera, mer eller mindre bara att, i Visual Studio 2019, högerklicka på projektet och lägga till docker-support.