Quantcast
Channel: damienbod – Software Engineering
Viewing all articles
Browse latest Browse all 352

ASP.NET Core IdentityServer4 Resource Owner Password Flow with custom UserRepository

$
0
0

This article shows how a custom user store or repository can be used in IdentityServer4. This can be used for an existing user management system which doesn’t use Identity or request user data from a custom source. The Resource Owner Flow using refresh tokens is used to access the protected data on the resource server. The client is implemented using IdentityModel.

Code: https://github.com/damienbod/AspNetCoreIdentityServer4ResourceOwnerPassword

Setting up a custom User Repository in IdentityServer4

To create a custom user store, an extension method needs to be created which can be added to the AddIdentityServer() builder. The .AddCustomUserStore() adds everything required for the custom user management.

services.AddIdentityServer()
		.AddSigningCredential(cert)
		.AddInMemoryIdentityResources(Config.GetIdentityResources())
		.AddInMemoryApiResources(Config.GetApiResources())
		.AddInMemoryClients(Config.GetClients())
		.AddCustomUserStore();
}

The extension method adds the required classes to the ASP.NET Core dependency injection services. A user respository is used to access the user data, a custom profile service is added to add the required claims to the tokens, and a validator is also added to validate the user credentials.

using CustomIdentityServer4.UserServices;

namespace Microsoft.Extensions.DependencyInjection
{
    public static class CustomIdentityServerBuilderExtensions
    {
        public static IIdentityServerBuilder AddCustomUserStore(this IIdentityServerBuilder builder)
        {
            builder.Services.AddSingleton<IUserRepository, UserRepository>();
            builder.AddProfileService<CustomProfileService>();
            builder.AddResourceOwnerValidator<CustomResourceOwnerPasswordValidator>();

            return builder;
        }
    }
}

The IUserRepository interface adds everything required by the application to use the custom user store throughout the IdentityServer4 application. The different views, controllers, use this interface as required. This can then be changed as required.

namespace CustomIdentityServer4.UserServices
{
    public interface IUserRepository
    {
        bool ValidateCredentials(string username, string password);

        CustomUser FindBySubjectId(string subjectId);

        CustomUser FindByUsername(string username);
    }
}

The CustomUser class is the the user class. This class can be changed to map the user data defined in the persistence medium.

namespace CustomIdentityServer4.UserServices
{
    public class CustomUser
    {
            public string SubjectId { get; set; }
            public string Email { get; set; }
            public string UserName { get; set; }
            public string Password { get; set; }
    }
}

The UserRepository implements the IUserRepository interface. Dummy users are added in this example to test. If you using a custom database, or dapper, or whatever, you could implement the data access logic in this class.

using System.Collections.Generic;
using System.Linq;
using System;

namespace CustomIdentityServer4.UserServices
{
    public class UserRepository : IUserRepository
    {
        // some dummy data. Replce this with your user persistence.
        private readonly List<CustomUser> _users = new List<CustomUser>
        {
            new CustomUser{
                SubjectId = "123",
                UserName = "damienbod",
                Password = "damienbod",
                Email = "damienbod@email.ch"
            },
            new CustomUser{
                SubjectId = "124",
                UserName = "raphael",
                Password = "raphael",
                Email = "raphael@email.ch"
            },
        };

        public bool ValidateCredentials(string username, string password)
        {
            var user = FindByUsername(username);
            if (user != null)
            {
                return user.Password.Equals(password);
            }

            return false;
        }

        public CustomUser FindBySubjectId(string subjectId)
        {
            return _users.FirstOrDefault(x => x.SubjectId == subjectId);
        }

        public CustomUser FindByUsername(string username)
        {
            return _users.FirstOrDefault(x => x.UserName.Equals(username, StringComparison.OrdinalIgnoreCase));
        }
    }
}

The CustomProfileService uses the IUserRepository to get the user data, and adds the claims for the user to the tokens, which are returned to the client, if the user/application was validated.

using System.Security.Claims;
using System.Threading.Tasks;
using IdentityServer4.Extensions;
using IdentityServer4.Models;
using IdentityServer4.Services;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;

namespace CustomIdentityServer4.UserServices
{
    public class CustomProfileService : IProfileService
    {
        protected readonly ILogger Logger;


        protected readonly IUserRepository _userRepository;

        public CustomProfileService(IUserRepository userRepository, ILogger<CustomProfileService> logger)
        {
            _userRepository = userRepository;
            Logger = logger;
        }


        public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            var sub = context.Subject.GetSubjectId();

            Logger.LogDebug("Get profile called for subject {subject} from client {client} with claim types {claimTypes} via {caller}",
                context.Subject.GetSubjectId(),
                context.Client.ClientName ?? context.Client.ClientId,
                context.RequestedClaimTypes,
                context.Caller);

            var user = _userRepository.FindBySubjectId(context.Subject.GetSubjectId());

            var claims = new List<Claim>
            {
                new Claim("role", "dataEventRecords.admin"),
                new Claim("role", "dataEventRecords.user"),
                new Claim("username", user.UserName),
                new Claim("email", user.Email)
            };

            context.IssuedClaims = claims;
        }

        public async Task IsActiveAsync(IsActiveContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = _userRepository.FindBySubjectId(context.Subject.GetSubjectId());
            context.IsActive = user != null;
        }
    }
}

The CustomResourceOwnerPasswordValidator implements the validation.

using IdentityServer4.Validation;
using IdentityModel;
using System.Threading.Tasks;

namespace CustomIdentityServer4.UserServices
{
    public class CustomResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator
    {
        private readonly IUserRepository _userRepository;

        public CustomResourceOwnerPasswordValidator(IUserRepository userRepository)
        {
            _userRepository = userRepository;
        }

        public Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
        {
            if (_userRepository.ValidateCredentials(context.UserName, context.Password))
            {
                var user = _userRepository.FindByUsername(context.UserName);
                context.Result = new GrantValidationResult(user.SubjectId, OidcConstants.AuthenticationMethods.Password);
            }

            return Task.FromResult(0);
        }
    }
}

The AccountController is configured to use the IUserRepository interface.

   public class AccountController : Controller
    {
        private readonly IIdentityServerInteractionService _interaction;
        private readonly AccountService _account;
        private readonly IUserRepository _userRepository;

        public AccountController(
            IIdentityServerInteractionService interaction,
            IClientStore clientStore,
            IHttpContextAccessor httpContextAccessor,
            IUserRepository userRepository)
        {
            _interaction = interaction;
            _account = new AccountService(interaction, httpContextAccessor, clientStore);
            _userRepository = userRepository;
        }

        /// <summary>
        /// Show login page
        /// </summary>
        [HttpGet]

Setting up a grant type ResourceOwnerPasswordAndClientCredentials to use refresh tokens

The grant type ResourceOwnerPasswordAndClientCredentials is configured in the GetClients method in the IdentityServer4 application. To use refresh tokens, you must add the IdentityServerConstants.StandardScopes.OfflineAccess to the allowed scopes. Then the other refresh token settings can be set as required.

public static IEnumerable<Client> GetClients()
{
	return new List<Client>
	{
		new Client
		{
			ClientId = "resourceownerclient",

			AllowedGrantTypes = GrantTypes.ResourceOwnerPasswordAndClientCredentials,
			AccessTokenType = AccessTokenType.Jwt,
			AccessTokenLifetime = 120, //86400,
			IdentityTokenLifetime = 120, //86400,
			UpdateAccessTokenClaimsOnRefresh = true,
			SlidingRefreshTokenLifetime = 30,
			AllowOfflineAccess = true,
			RefreshTokenExpiration = TokenExpiration.Absolute,
			RefreshTokenUsage = TokenUsage.OneTimeOnly,
			AlwaysSendClientClaims = true,
			Enabled = true,
			ClientSecrets=  new List<Secret> { new Secret("dataEventRecordsSecret".Sha256()) },
			AllowedScopes = {
				IdentityServerConstants.StandardScopes.OpenId,
				IdentityServerConstants.StandardScopes.Profile,
				IdentityServerConstants.StandardScopes.Email,
				IdentityServerConstants.StandardScopes.OfflineAccess,
				"dataEventRecords"
			}
		}
	};
}

When the token client requests a token, the offline_access must be sent in the HTTP request, to recieve a refresh token.

private static async Task<TokenResponse> RequestTokenAsync(string user, string password)
{
	return await _tokenClient.RequestResourceOwnerPasswordAsync(
		user,
		password,
		"email openid dataEventRecords offline_access");
}

Running the application

When all three applications are started, the console application gets the tokens from the IdentityServer4 application and the required claims are returned to the console application in the token. Not all the claims need to be added to the access_token, only the ones which are required on the resource server. If the user info is required in the UI, a separate request can be made for this info.

Here’s the token payload returned from the server to the client in the token. You can see the extra data added in the profile service, for example the role array.

{
  "nbf": 1492161131,
  "exp": 1492161251,
  "iss": "https://localhost:44318",
  "aud": [
    "https://localhost:44318/resources",
    "dataEventRecords"
  ],
  "client_id": "resourceownerclient",
  "sub": "123",
  "auth_time": 1492161130,
  "idp": "local",
  "role": [
    "dataEventRecords.admin",
    "dataEventRecords.user"
  ],
  "username": "damienbod",
  "email": "damienbod@email.ch",
  "scope": [
    "email",
    "openid",
    "dataEventRecords",
    "offline_access"
  ],
  "amr": [
    "pwd"
  ]
}

The token is used to get the data from the resource server. The client uses the access_token and adds it to the header of the HTTP request.

HttpClient httpClient = new HttpClient();
httpClient.SetBearerToken(access_token);

var payloadFromResourceServer = await httpClient.GetAsync("https://localhost:44365/api/DataEventRecords");
if (!payloadFromResourceServer.IsSuccessStatusCode)
{
	Console.WriteLine(payloadFromResourceServer.StatusCode);
}
else
{
	var content = await payloadFromResourceServer.Content.ReadAsStringAsync();
	Console.WriteLine(JArray.Parse(content));
}

The resource server validates each request using the UseIdentityServerAuthentication middleware extension method.

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
IdentityServerAuthenticationOptions identityServerValidationOptions = new IdentityServerAuthenticationOptions
{
	Authority = "https://localhost:44318/",
	AllowedScopes = new List<string> { "dataEventRecords" },
	ApiSecret = "dataEventRecordsSecret",
	ApiName = "dataEventRecords",
	AutomaticAuthenticate = true,
	SupportedTokens = SupportedTokens.Both,
	// TokenRetriever = _tokenRetriever,
	// required if you want to return a 403 and not a 401 for forbidden responses
	AutomaticChallenge = true,
};

app.UseIdentityServerAuthentication(identityServerValidationOptions);

Each API is protected using the Authorize attribute with policies if needed. The HttpContext can be used to get the claims sent with the token, if required. The username is sent with the access_token in the header.

[Authorize("dataEventRecordsUser")]
[HttpGet]
public IActionResult Get()
{
	var userName = HttpContext.User.FindFirst("username")?.Value;
	return Ok(_dataEventRecordRepository.GetAll());
}

The client gets a refresh token and updates periodically in the client. You could use a background task to implement this in a desktop or mobile application.

public static async Task RunRefreshAsync(TokenResponse response, int milliseconds)
{
	var refresh_token = response.RefreshToken;

	while (true)
	{
		response = await RefreshTokenAsync(refresh_token);

		// Get the resource data using the new tokens...
		await ResourceDataClient.GetDataAndDisplayInConsoleAsync(response.AccessToken);

		if (response.RefreshToken != refresh_token)
		{
			ShowResponse(response);
			refresh_token = response.RefreshToken;
		}

		Task.Delay(milliseconds).Wait();
	}
}

The application then loops forever.

Links:

https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow

https://github.com/IdentityModel/IdentityModel2

https://github.com/IdentityServer/IdentityServer4

https://github.com/IdentityServer/IdentityServer4.Samples



Viewing all articles
Browse latest Browse all 352

Trending Articles