ASP.NET
Core Identity
Server 4 Resource Server
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
▋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
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
▋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).
▋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
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
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
您好~看了你的大作後,我下載您提供的sample發現無法編譯,因缺少JB.Infra專案,請問哪邊可以下載呢?謝謝
回覆刪除抱歉,太晚看到這則留言~
刪除我已經發了一個PR修正這個問題,請參考這個ISSUE
https://github.com/KarateJB/AspNetCore.IdentityServer4.Sample/issues/17