2019年7月25日 星期四

[ASP.NET Core] Identity Server 4 – Secure Web API


 ASP.NET Core   Identity Server 4   Resource Server  


Introduction


After having the OpenLDAP container and Auth Server (IdentityServer4), we can build the Backend API Server that will be secure by the Auth Server.

The goals are
·  Sign in with LDAP authentication by Identity Server 4
·  Get an Access token(JWT) after authorized successfully
·  Secure the APIs
·  Use the Access token to access the secured API


Environment


docker-openldap 1.2.4 (OpenLDAP 2.4.47)
ASP.NET Core 2.2.203
IdentityServer4 2.4.0
Nordes/IdentityServer4.LdapExtension 2.1.8
IdentityModel 3.10.10



Initialize Project


The source code is on my Github.


Create new dotnet Web API project

Lets create the other ASP.NET Core Web API project as the Backend Server,

$ dotnet new webapi --name AspNetCore.IdentityServer4.WebApi
$ dotnet sln AspNetCore.IdentityServer4.sln add AspNetCore.IdentityServer4.WebApi/AspNetCore.IdentityServer4.WebApi.csproj



Install Nuget Packages



Or by dotnet CLI

$ cd AspNetCore.IdentityServer4.WebApi
$ dotnet add package IdentityModel --version 3.10.10




Implement



Enable Authentication/Authorization

Now assume that the Auth Server is listening on https://localhost:6001 and has the following Resources and Client config: (See code in Github)

(Auth Server) InMemoryInitConfig.cs

public class InMemoryInitConfig
{
        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new ApiResource[]
            {
                new ApiResource("MyBackendApi1", "My Backend API 1"),
                new ApiResource("MyBackendApi2", "My Backend API 2"),
            };
        }

        public static IEnumerable<Client> GetClients()
        {
            return new[]
            {
                new Client
                {
                    Enabled = true,
                    ClientId = "MyBackend",
                    ClientName = "MyBackend Client",
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                    AccessTokenType = AccessTokenType.Jwt,
                    AllowedScopes = {
                        "MyBackendApi1",
                        "MyBackendApi2",
                    },
                    AlwaysSendClientClaims = true,
                    UpdateAccessTokenClaimsOnRefresh = true,
                    AlwaysIncludeUserClaimsInIdToken = true,
                    AllowAccessTokensViaBrowser = true,
                    IncludeJwtId = true,
                    ClientSecrets = { new Secret("secret".Sha256()) },
                    AllowOfflineAccess = true,
                    AccessTokenLifetime = 3600,
                }
            };
        }
}


On Backend Server’s Startup.cs, enable authentication by setting Default Authenticate Scheme to “Bearer” and JwtBearer options as following,

Startup.cs : ConfigureServices

public void ConfigureServices(IServiceCollection services)
{
          services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

            services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            }).AddJwtBearer(options =>
            {
                options.Authority = "https://localhost:6001"; // Base-address of your identityserver
                options.RequireHttpsMetadata = true;
                options.Audience = "MyBackendApi1"; // API Resource name
                options.TokenValidationParameters.ClockSkew = TimeSpan.Zero; // The JWT security token handler allows for 5 min clock skew in default
            });
 }

Notice that the option: Audience, must be set to one of the API resources, such as "MyBackendApi1" or " MyBackendApi2".

And enable the Authentication Middleware.

Startup.cs : Configure

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
            // Authentication
            app.UseAuthentication();

            app.UseHttpsRedirection();
            app.UseMvc();
}


Create Authenticate Service

Let’s create Identity Client Service:

Interface: IIdentityClient.cs
Implement: IdentityClient.cs

that can discover the endpoints of Auth Server and then send request(s) to the endpoint(s).

However, we have to inject the HttpClient and related Auth Service into IServiceCollection.

Startup.cs : ConfigureServices

public void ConfigureServices(IServiceCollection services)
{
            // skip…
           
            // Inject HttpClient
            services.AddHttpClient<IIdentityClient, IdentityClient>().SetHandlerLifetime(TimeSpan.FromMinutes(2)); // HttpMessageHandler lifetime = 2 min

}
                                                     

Now we can implement the Authentication Service.

IIdentityClient.cs

public interface IIdentityClient
{
     Task<TokenResponse> SignInAsync(string userName, string password);
}



IdentityClient.cs

We use the HttpClient extension method:  GetDiscoveryDocumentAsync, to get the TokenEndpoint for requesting a token by RequestPasswordTokenAsync method.

Notice that we have to set the ClientId and ClientSecret that maps to the Client’s configuration in Auth Server.

public class IdentityClient : IIdentityClient
{
        private const string SECRETKEY = "secret";
        private readonly AppSettings configuration = null;
        private readonly HttpClient httpClient = null;
        private readonly string remoteServiceBaseUrl = "https://localhost:6001";

        public IdentityClient(
            IOptions<AppSettings> configuration,
            HttpClient httpClient)
        {
            this.httpClient = httpClient;
            this.remoteServiceBaseUrl = this.configuration.Host.AuthServer;
        }

        public async Task<TokenResponse> SignInAsync(string userName, string password)
        {
            var discoResponse = await this.discoverDocument();

            TokenResponse tokenResponse = await this.httpClient.RequestPasswordTokenAsync(new PasswordTokenRequest
            {
                Address = discoResponse.TokenEndpoint,
                ClientId = "MyBackend",
                ClientSecret = SECRETKEY,
                UserName = userName,
                Password = password,
            });

            return tokenResponse;
        }

        private async Task<DiscoveryResponse> discoverDocument()
        {
            var discoResponse = await this.httpClient.GetDiscoveryDocumentAsync(new DiscoveryDocumentRequest
            {
                Address = this.remoteServiceBaseUrl,
                Policy =
                {
                    RequireHttps = true // default: true
                }
            });

            if (discoResponse.IsError)
            {
                throw new Exception(discoResponse.Error);
            }

            return discoResponse;
        }
}



Create Sign-in API

Since we have the Auth Service, lets create an API for Sign-in.

AuthController.cs

[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{

private readonly IIdentityClient auth = null;

        public AuthController(IIdentityClient id4Client)
        {
            this.auth = id4Client;
        }

        // GET api/values
        [HttpPost("SignIn")]
        [AllowAnonymous]
        public async Task<JObject> SignIn(LdapUser user)
        {
            var tokenResponse = await this.auth.SignInAsync(user.Username, user.Password);
            
            if (!tokenResponse.IsError)
            {
                return tokenResponse.Json;
            }

            this.HttpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
            return null;
        }
}
            

The API model: LdapUser:

public class LdapUser
{
         /// <summary>
        /// User name
        /// </summary>
        public string Username { get; set; }

        /// <summary>
        /// User password
        /// </summary>
        public string Password { get; set; }
}


Here is the response of sign-in a LDAP user.



Secure the APIs

Now we can use Authorize Attribute to secure our APIs.
Take ValuesController for example,

namespace AspNetCore.IdentityServer4.WebApi.Controllers
{
    [Route("api/[controller]")]
    [Authorize]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        [HttpGet]
        public ActionResult<IEnumerable<string>> Get()
        {
            return new string[] { "value1", "value2" };
        }
}

If we request the secured API without Access Token, we will get a 401-Unauthorized response.
On the other hand, we can successfully access the API with Access Token as following,




Request the User Profile from LDAP

We can ask for the LDAP user’s information thru Authentication Server.
But first we have to include the Identity Resources into the Client’s allowed scopes on Authentication Server.

(Auth Server) InMemoryInitConfig.cs

Update the Client’s AllowedScopes as the yellow codes,

public class InMemoryInitConfig
{
        public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new IdentityResource[]
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Email(),
                new IdentityResources.Profile(),
                new IdentityResources.Phone(),
                new IdentityResources.Address()
            };
        }

        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new ApiResource[]
            {
                new ApiResource("MyBackendApi1", "My Backend API 1"),
                new ApiResource("MyBackendApi2", "My Backend API 2"),
            };
        }

        public static IEnumerable<Client> GetClients()
        {
            return new[]
            {
                new Client
                {
                    Enabled = true,
                    ClientId = "MyBackend",
                    ClientName = "MyBackend Client",
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                    AccessTokenType = AccessTokenType.Jwt,
                    AllowedScopes = {
                        "MyBackendApi1",
                        "MyBackendApi2",
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Email,
                        IdentityServerConstants.StandardScopes.Profile,
                    },
                    AlwaysSendClientClaims = true,
                    UpdateAccessTokenClaimsOnRefresh = true,
                    AlwaysIncludeUserClaimsInIdToken = true,
                    AllowAccessTokensViaBrowser = true,
                    IncludeJwtId = true,
                    ClientSecrets = { new Secret("secret".Sha256()) },
                    AllowOfflineAccess = true,
                    AccessTokenLifetime = 3600,
                }
            };
        }
}

Important! The Allowed Scopes MUST include the Identity Resource: "openid", when other Identity Resource is included as well. Or a Forbidden error will be put into the response object while requesting the user’s information as below.





Now we can go back to the Backend server’s project, create an API to request for user’s information by Access Token.

IdentityClient.cs

Add a new method, GetUserInfoAsync, on both IIdentityClient and IdentityClient.
Notice that we need the Access Token as the parameter for requesting user’s information.

public class IdentityClient : IIdentityClient
{
        private const string SECRETKEY = "secret";
        private readonly HttpClient httpClient = null;
        private readonly string remoteServiceBaseUrl = "https://localhost:6001";

        public IdentityClient(HttpClient httpClient)
        {
            this.httpClient = httpClient;
            this.remoteServiceBaseUrl = this.configuration.Host.AuthServer;
        }
       
// Skip SignInAsync method…
       
        public async Task<UserInfoResponse> GetUserInfoAsync(string accessToken)
        {
            var discoResponse = await this.discoverDocument();

            UserInfoResponse userInfoResponse = await this.httpClient.GetUserInfoAsync(new UserInfoRequest()
            {
                Address = discoResponse.UserInfoEndpoint,
                Token = accessToken
            });

            return userInfoResponse;
        }

        private async Task<DiscoveryResponse> discoverDocument()
        {
            var discoResponse = await this.httpClient.GetDiscoveryDocumentAsync(new DiscoveryDocumentRequest
            {
                Address = this.remoteServiceBaseUrl,
                Policy =
                {
                    RequireHttps = true // default: true
                }
            });

            if (discoResponse.IsError)
            {
                throw new Exception(discoResponse.Error);
            }

            return discoResponse;
        }
    }


AuthController.cs

[HttpPost("UserInfo")]
public async Task<JObject> UserInfo([FromBody] string accessToken)
{
           var userInfoResponse = await this.auth.GetUserInfoAsync(accessToken);

            if (!userInfoResponse.IsError)
            {
                return userInfoResponse.Json;
            }

            this.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest;
            return null;
}


Result:




Source Code





Reference






2 則留言:

  1. 您好~看了你的大作後,我下載您提供的sample發現無法編譯,因缺少JB.Infra專案,請問哪邊可以下載呢?謝謝

    回覆刪除
    回覆
    1. 抱歉,太晚看到這則留言~
      我已經發了一個PR修正這個問題,請參考這個ISSUE
      https://github.com/KarateJB/AspNetCore.IdentityServer4.Sample/issues/17

      刪除