Create a custom accounts provider

1. Overview

This sample shows how to create a custom accounts provider. You will learn the following:

  • What is a custom accounts provider
  • How to create an extension
  • How to create a custom accounts provider

2. About custom accounts providers

A custom accounts provider manages user accounts and authentication. Out of the box, you can authenticate your users in three ways:

  • Using the application's administration interface to define user accounts.
  • Using an Active Directory server (Windows authentication) to provide user account information.
  • Configuring federated authentication to use a third-party identity provider (OpenID Connect, Google, Microsoft Azure/Office365 Identity, etc.)

While the above options are enough for many networks, a custom accounts provider can allow you to authenticate against any other custom or legacy provider to integrate the application with your existing systems and networks.

Note
Authentication is handled by extensions, but authorization is handled internally by the application to enforce licensing restrictions.

The following image demonstrates the difference between using standard local or Windows authentication, and using a custom accounts provider extension:

Left: Built-in accounts provider, Right: Custom accounts provider
Left: Built-in accounts provider, Right: Custom accounts provider

3. Getting started

The current version of the sample solution targets both .NET Framework and .NET Core so that the packaged extension will work for version 7 of the application and higher on all platforms.

The following prerequisites must be installed on your computer to build the provided sample without modifications:

  • Visual Studio 2017 or higher
  • Microsoft .NET Framework 4.7.2
  • Microsoft .NET Core 3.1

You can modify the sample project to target different versions of .NET if preferred depending on the version used by your application instance.

The NuGet packages referenced as dependencies are initially set to version 7. If your version of the application is newer and the APIs you are using may have changed, you can update the version of this package reference.

3.1. Downloading sample solution

To download the custom accounts provider sample solution, click here.

This sample was updated on April 8, 2022 to add .NET Core support.

(Previous versions of the sample solution are also available for version 6, versions 2.5 through 5.0, version 2.0, and version 1.)

3.2. Opening solution

Extract CustomAccountsProviderSample.zip to a folder and open the solution in Microsoft Visual Studio to build the extension.

To use the option to publish directly to your application instance, run Visual Studio as an administrator before opening the solution. For example, you can right-click Visual Studio in the start menu and find the Run as administrator option.

The solution file is located at:
[Extracted Folder]\CustomAccountsProviderSample\CustomAccountsProviderSample.sln

4. The project

The project is a class library.

  • AccountInformation.csv - Comma separated values containing user information. This is used as the back end of the accounts provider for demonstration purposes.
  • CustomAccountProvider.cs - This class contains the implementation of the custom account provider.
  • CustomAccountProviderSamplePackageInfo.cs - This class contains the package information about the extension package.
  • PasswordInformation.csv - Comma separated values containing user password information. These are left in plain text for demonstration purposes.
  • PublishExtensionTemplate.props - Used for auto publishing the extension after the build succeeds, and defines extension properties, and files.

4.1. ExtensionPackageInfo class

In order for an accounts provider to be recognized, it needs to contain a class that extends the ExtensionPackageInfo2 class. This class contains the extension package information.

In this sample, the OnLoaded method is also overridden to copy the CSV file resources used for demonstration purposes to the application data folder where it can be modified. Account data should not be stored in text files.

/// <summary>
/// This class contains the package information about the extension package.
/// </summary>
public class CustomAccountProviderSamplePackageInfo : ExtensionPackageInfo2
{
    /// <summary>Initializes a new instance of the 
    /// <see cref="CustomAccountProviderSamplePackageInfo"/> class.</summary>
    /// <param name="extensionManifest">The extension manifest.</param>
    public CustomAccountProviderSamplePackageInfo(ExtensionManifest extensionManifest)
        : base(extensionManifest)
    {
    }

    /// <summary>
    /// Called after the extension package is loaded during engine startup.
    /// </summary>
    public override void OnLoaded()
    {
        ...
    }
}
        

4.2. Publish extension template

This sample has a mechanism to automatically publish the extension when the project is built, which is the Dundas.BI.PublishExtension NuGet package. When this package is added to the project, it creates a PublishExtensionTemplate.props file containing MSBuild property and item groups, which define how to create and publish the extension.

When the DtFilePath property is set to the file path of the dt tool of an application instance, it will then publish the extension directly to that instance when you build the solution. It will also touch the web.config file to force the web application to reset.

If the DtFilePath property is not set, it will create a .zip file you can add to your application instance using the Extensions page in the administration UI. After building the solution with default settings while set to the Release solution configuration, this .zip file can be found in the bin\Release\netcoreapp3.1 subfolder of your solution. It targets both .NET Framework and .NET Core.

This sample copies the files AccountInformation.csv and PasswordInformation.csv used for demonstration purposes to the application data folder. The files are initially published using extension file resources as defined in the template below, and they are copied in the OnLoaded event in the CustomAccountProviderSamplePackageInfo class.

<Project>
  
  <Target Name="DefineDundasBIExtensionProperties" AfterTargets="CopyFilesToOutputDirectory">
    <!-- Properties used to publish extension -->
    <PropertyGroup>

      <!-- Extension Author -->
      <ExtensionAuthor>MyCompany Sample Author</ExtensionAuthor>
      <!-- Extension Name -->
      <ExtensionName>$(AssemblyName)</ExtensionName>
      <!-- Extension Display Name -->
      <ExtensionDisplayName>$(AssemblyName)</ExtensionDisplayName>
      <!-- Extension Folder Name -->
      <ExtensionFolderName>$(AssemblyName)</ExtensionFolderName>
      <!-- Extension Main Assembly Name -->
      <ExtensionMainAssemblyName>$(AssemblyName).dll</ExtensionMainAssemblyName>
      <!-- Extension Id -->
      <ExtensionId>c4889691-ab7f-452b-ba16-fb3bcda46724</ExtensionId>
      <!-- Extension Copyright -->
      <ExtensionCopyright>Copyright (c)</ExtensionCopyright>
      <!-- Extension Version -->
      <ExtensionVersion>1.0.0.0</ExtensionVersion>
      <!-- The outfolder where the extension zip file will be left. -->
      <ExtensionOutputFolder>$(OutputPath)</ExtensionOutputFolder>
      <!-- DT -->
      <!-- If this is specified the extension will be installed using dt.  -->
      <DtFilePath></DtFilePath>

      <!-- Framework Folder -->
      <FrameworkFolderRelative>$(OutputPath)\..</FrameworkFolderRelative>
      <FrameworkFolder>$([System.IO.Path]::GetFullPath($(FrameworkFolderRelative)))</FrameworkFolder>
      
    </PropertyGroup>

    <!-- Define files to include -->
    <ItemGroup>
      <!-- Define the NetFramework assemblies -->
      <NetfwAssemblies Include="$(FrameworkFolder)\net472\$(AssemblyName).*" />
      <!-- Define the NetCore assemblies -->
      <NetCoreAssemblies Include="$(FrameworkFolder)\netcoreapp3.1\$(AssemblyName).*" />
      <!-- Define any app resources for the extension. -->
      <AppResources />
      <!-- Define any file resources for the extension. -->
      <FileResources Include="$(MSBuildThisFileDirectory)\AccountInformation.csv" />
      <FileResources Include="$(MSBuildThisFileDirectory)\PasswordInformation.csv" />
      <!-- Define any localization files for extension. -->
      <Localizations />
      <!-- Define Extension Supported Runtimes -->
      <ExtensionSupportedRuntimes Include="NetFramework" />
      <ExtensionSupportedRuntimes Include="NetCore" />
    </ItemGroup>
  </Target>
</Project>

For more details on using this package to automate publishing extensions, see Using the Dundas.BI.PublishExtension NuGet package.

4.3. Defining the custom account provider

In order to create a custom account provider, you will need to implement the IAccountsProvider2 Interface.

using Dundas.BI;
using Dundas.BI.AccountServices;
using Dundas.BI.AccountServices.Extensibility;
  ...
namespace MyCompany.Sample.CustomAccountsProviderSample
{
    /// <summary>
    /// This class represents a simple accounts provider that uses a csv file. 
    /// </summary>
    public class CustomAccountProvider : IAccountsProvider2
    {
      ...
    }
}

4.4. Implementing the IAccountsProvider2 interface

4.4.1. Defining the supported query operations

The supported query operation specifies one or more operations available in the accounts provider. In the following example, we return the Count operation. This means we should implement a System.NotSupportedException for the query method on the interface, and we should implement the QueryCount method.

/// <summary>
/// Gets the supported query operations.
/// </summary>
/// <value>
/// The supported query operations.
/// </value>
public Dundas.BI.Utility.QueryApiOperations SupportedQueryOperations
{
	get { return Dundas.BI.Utility.QueryApiOperations.Count; }
}

/// <summary>
/// This operation is not supported by this account provider.
/// </summary>
/// <exception cref="System.NotSupportedException"></exception>
public IList<Guid> Query(
    int pageNumber, 
    int pageSize, 
    IList<Tuple<Dundas.BI.AccountServices.AccountQueryField, 
    Dundas.BI.SortDirection>> orderBy, 
    ICollection<Dundas.BI.AccountServices.AccountQueryFilterRule> filter
)
{
	throw new NotSupportedException();
}

/// <summary>
/// Queries the amount of users.
/// </summary>
/// <param name="filter">The filter.</param>
/// <returns>The number of accounts.</returns>
public int QueryCount(ICollection<Dundas.BI.AccountServices.AccountQueryFilterRule> filter)
{
	return GetAllRecords().Count();
}

4.4.2. Defining if modify is supported

The IsModifySupported property indicates whether the provider supports saving changes. If true, all the methods that change the account should be implemented. Otherwise, they should throw a System.NotSupportedException.

This example demonstrates how to set the custom account provider to modify the user accounts:

/// <summary>
/// Gets a value indicating whether modify is supported.
/// </summary>
/// <value>
/// <c>true</c> if modify is supported; otherwise, <c>false</c>.
/// </value>
public bool IsModifySupported
{
	get { return true; }
}

/// <summary>
/// Queries the amount of users.
/// </summary>
/// <param name="filter">The filter.</param>
/// <returns>The number of accounts.</returns>
public int QueryCount(ICollection<Dundas.BI.AccountServices.AccountQueryFilterRule> filter)
{
	return GetAllRecords().Count();
}

/// <summary>
/// Saves the record.
/// </summary>
/// <param name="record">The record.</param>
/// <returns>A Guid representing the id of the item it just saved.</returns>
public Guid SaveRecord(AccountData record)
{
    
    AccountData[] listToSave;

    if (record.Id.Equals(Guid.Empty))
    {
        // Record is new
        record.Id = Guid.NewGuid();
        listToSave = GetAllRecords().Concat(new AccountData[] { record }).ToArray();
    }
    else
    {
        // Existing record
        listToSave = GetAllRecords().ToArray();

        for (int index = 0; index < listToSave.Count(); index++)
        {
            if (listToSave[index].Id.Equals(record.Id))
            {
                listToSave[index] = record;

                break;
            }
        }   
    }

    LocalUserAccountData localUserAccountData = (LocalUserAccountData)record;

    if (!string.IsNullOrEmpty(localUserAccountData.Password))
    {
        SavePassword(localUserAccountData.Id, localUserAccountData.Password);
    }

    UpdatePasswordIfNecessary(record);

    SaveAllAccountDataToFile(listToSave);

	return record.Id;

}	        

/// <summary>
/// Deletes the record.
/// </summary>
/// <param name="recordId">The record identifier.</param>
public void DeleteRecord(Guid recordId)
{
    SaveAllAccountDataToFile(GetAllRecords().Where(record => !record.Id.Equals(recordId)));
}

private void SavePassword(Guid accountId, string password)
{
    IDictionary<Guid, string> passwords = GetAllPasswords();

    passwords[accountId] = password;

    SaveAllPasswordsToFile(passwords);

}


/// <summary>
/// Updates a local user account's password.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="newPassword">The new password.</param>
public void SetLocalUserAccountNewPassword(Guid accountId, string newPassword)
{
    SavePassword(accountId, newPassword);
}

/// <summary>
/// Updates the logon timestamp and logon count of a 
/// <see cref="T:Dundas.BI.AccountServices.LocalUserAccount" />
/// or a <see cref="T:Dundas.BI.AccountServices.WindowsUserAccount" />.
/// </summary>
/// <param name="accountId">The ID of the account to update.</param>
/// <param name="logOnTimestamp">The logon timestamp (UTC).</param>
public void UpdateLogOnTimestampAndCount(Guid accountId, DateTime logOnTimestamp)
{
   // This method is not called in IAccountsProvider2, 
   //is replaced by UpdateDynamicAccountProperties.
}

4.4.3. Authenticating users

To authenticate users, the ValidateLocalUserLogOnCredentials method is used. This method returns true if the credentials are valid; otherwise, false.

This example demonstrates how to implement the ValidateLocalUserLogOnCredentials and PopulateLocalLogOnContext methods.

/// <summary>
/// Validates local user account logon credentials.
/// </summary>
/// <param name="context">The logon context.</param>
/// <returns>
///   <see langword="true" /> if the credentials are valid; 
///       otherwise, <see langword="false" />.
/// </returns>
/// <exception cref="System.ArgumentNullException">context</exception>
public bool ValidateLocalUserLogOnCredentials(LocalLogOnContext context)
{
    // Param validation.
    if (context == null)
    {
        throw new ArgumentNullException("context");
    }

    LogOnCredential usernameCredential = 
        context.Credentials.First(c => c.Id == LogOnCredentialIds.AccountName);
    LogOnCredential passwordCredential = 
        context.Credentials.First(c => c.Id == LogOnCredentialIds.Password);

    IEnumerable<AccountData> accountDataList = GetAllRecords();

    AccountData accountData = accountDataList.FirstOrDefault(
        accountDataItem => accountDataItem.Name.Equals(usernameCredential.Value)
    );

    LocalUserAccountData userAccount = (LocalUserAccountData)accountData;   

    if (passwordCredential.Value.Equals(GetPassword(accountData.Id)))
    {
        // Nothing went wrong - return success.
        return true;
    }
    else
    {               
        // Passwords did not match.
        return false;

    }

}

/// <summary>
/// Populates the local log on context.
/// </summary>
/// <param name="context">The context.</param>
public void PopulateLocalLogOnContext(LocalLogOnContext context)
{
    // Param validation.
    if (context == null)
    {
        throw new ArgumentNullException("context");
    }

    if (context.Credentials.Any(c => c.Id == LogOnCredentialIds.AccountName) == false
        || context.Credentials.Any(c => c.Id == LogOnCredentialIds.Password) == false)
    {
        // No username or password provided.
        return;
    }

    LogOnCredential usernameCredential = context.Credentials.First(
        c => c.Id == LogOnCredentialIds.AccountName
    );
    LogOnCredential passwordCredential = context.Credentials.First(
        c => c.Id == LogOnCredentialIds.Password
    );

    IEnumerable<AccountData> accountDataList = GetAllRecords();

    AccountData accountData = accountDataList.FirstOrDefault(
        accountDataItem => accountDataItem.Name.Equals(usernameCredential.Value)
    );

    if (accountData == null)
    {
        // Account does not exist.
        return;
    }

    LocalUserAccountData localUserAccount = (LocalUserAccountData)accountData;

    // Populate log on context.
    context.AccountId = localUserAccount.Id;
    context.LockedUntil = localUserAccount.LockedUntil;
    context.FailedLogOnCount = localUserAccount.FailedLogOnCount;
}

4.4.4. Logon security

To lock out malicious brute force attacks, the provider must implement the ResetFailedLogOnInfo, and UpdateLogOnFailureInfo methods.

This example demonstrates how to implement these:

/// <summary>
/// Resets any information about failed logon attempts for the specified account.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
public void ResetFailedLogOnInfo(Guid accountId)
{
    IEnumerable<AccountData> accountDataList = GetAllRecords();

    LocalUserAccountData accountData = (LocalUserAccountData)accountDataList.First(
        accountDataItem => accountDataItem.Id.Equals(accountId)
    );

    accountData.FailedLogOnCount = 0;

    SaveAllAccountDataToFile(accountDataList);
}

/// <summary>
/// Updates the log on failure information.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="failedLogOnCount">
/// The new number of consecutive failed logon attempts.
/// </param>
/// <param name="lockedUntil">
/// The time until which the account is locked, or <see langword="null"
/// />
/// if the account is not yet locked.</param>
public void UpdateLogOnFailureInfo(Guid accountId, int failedLogOnCount, 
   DateTime? lockedUntil)
{
    // This method is not called in IAccountsProvider2, 
    // replaced by UpdateDynamicAccountProperties.
}

4.4.5. Updating dynamic account properties

This method updates the dynamic properties of an account. This example demonstrates how to implement this:

/// <summary>
/// Update the dynamic properties of an account.
/// </summary>
/// <param name="accountId">The ID of the account to update.</param>
/// <param name="properties">Dynamic properties of the account.</param>
public void UpdateDynamicAccountProperties(Guid accountId, DynamicAccountProperties properties)
{
    AccountData[] accountDataList = GetAllRecords().ToArray();

    for (int index = 0; index < accountDataList.Count(); index++)
    {
        if (accountDataList[index].Id.Equals(accountId))
        {
            LocalUserAccountData accountData = (LocalUserAccountData)accountDataList[index];

            if (properties.FailedLogOnCount != null)
            {
                accountData.FailedLogOnCount = properties.FailedLogOnCount.Item1;
            }

            if (properties.LockedUntil != null)
            {
                accountData.LockedUntil = properties.LockedUntil.Item1;
            }

            break;

        }
    }

    SaveAllAccountDataToFile(accountDataList);
}

Note
The UpdateDynamicAccountProperties method replaces the UpdateLogOnTimestampAndCount and UpdateLogOnFailureInfo methods in version 2.5 and above. These replaced methods will not be called if the account provider implements IAccountsProvider2.

4.4.6. Getting users

/// <summary>
/// Gets all user records.
/// </summary>
/// <returns>
/// A IEnumerable of <see cref="T:Dundas.BI.AccountServices.Extensibility.AccountData" />.
/// </returns>
public IEnumerable<AccountData> GetAllRecords()
{
    if (string.IsNullOrEmpty(this.AccountInformationFilePath))
    {
        return null;
    }

    List<AccountData> accountDataList = new List<AccountData>();

    foreach (string line in File.ReadAllLines(this.AccountInformationFilePath))
    {
        string[] splitLine = line.Split(',');

        DateTime? lockedUntil = null;

        if (!string.IsNullOrEmpty(splitLine[4]))
        {
            lockedUntil = DateTime.Parse(splitLine[4]);
        }

        DateTime? passwordExpiryDate = null;

        if (!string.IsNullOrEmpty(splitLine[6]))
        {
            passwordExpiryDate = DateTime.Parse(splitLine[6]);
        }

        DateTime? accountExpiryDate = null;

        if (!string.IsNullOrEmpty(splitLine[7]))
        {
            accountExpiryDate = DateTime.Parse(splitLine[7]);
        }

        accountDataList.Add(new LocalUserAccountData()
        {
            Id = new Guid(splitLine[0]),
            Name = splitLine[1],
            IsEnabled = bool.Parse(splitLine[2]),
            CanChangePassword = true,
            FailedLogOnCount = int.Parse(splitLine[3]),
            LockedUntil = lockedUntil,
            PasswordNeverExpires = bool.Parse(splitLine[5]),
            PasswordExpiryDate = passwordExpiryDate,
            AccountExpiryDate = accountExpiryDate

        });
    }

    return accountDataList;
}

/// <summary>
/// Gets a collection of <see cref="T:Dundas.BI.AccountServices.Extensibility.AccountData" />.
/// </summary>
/// <param name="recordIds">The record ids.</param>
/// <returns>
/// A collection of <see cref="T:Dundas.BI.AccountServices.Extensibility.AccountData" />.
/// </returns>
public ICollection<AccountData> GetById(ICollection<Guid> recordIds)
{
    Collection<AccountData> accountDataCollection = new Collection<AccountData>();

    var allRecords = GetAllRecords();

    foreach (Guid id in recordIds)
    {
        AccountData foundItem = allRecords.FirstOrDefault(
            accountDataItem => accountDataItem.Id.Equals(id)
        );

        if (foundItem != null)
        {
            accountDataCollection.Add(foundItem);
        }
    }

    return accountDataCollection;
}

/// <summary>
/// Retrieves an account specified by its name.
/// </summary>
/// <param name="accountName">The account's name.</param>
/// <returns>
/// The <see cref="T:Dundas.BI.AccountServices.Extensibility.AccountData" />
/// or <see langword="null" /> if it does not exist.
/// </returns>
public AccountData GetByName(string accountName)
{
    return GetAllRecords().FirstOrDefault(
        accountDataItem => accountDataItem.Name.Equals(
            accountName,
            StringComparison.CurrentCultureIgnoreCase
        )
    );
}

4.4.7. Importing and exporting users

To import and export users in the project manager, the ImportRecord and ExportRecord methods are used. This is demonstrated below:

/// <summary>
/// Export an account from the underlying storage mechanism.
/// </summary>
/// <param name="accountId">The ID of the account to be exported.</param>
/// <returns>
/// The account data to be exported.
/// </returns>
/// <exception cref="System.NotImplementedException"></exception>
public AccountData ExportRecord(Guid accountId)
{
	return GetAllRecords().FirstOrDefault(
		accountDataItem => accountDataItem.Id.Equals(accountId)
	);
}

/// <summary>
/// Imports a record to the underlying storage mechanism.
/// </summary>
/// <param name="record">The record to be imported.</param>
/// <returns><c>true</c> if a new record was imported; otherwise, <c>false</c>.</returns>
public bool ImportRecord(AccountData record)
{

    IEnumerable<AccountData> allAccounts = GetAllRecords();

    AccountData accountData = allAccounts.FirstOrDefault(
        accountItem => accountItem.Id.Equals(record.Id)
    );

    UpdatePasswordIfNecessary(record);

    if (accountData != null)
    {
        // Overwrite existing record.
        accountData = record;
        SaveAllAccountDataToFile(allAccounts);

        return false;
    }
    else
    {
        // New record.
        SaveAllAccountDataToFile(allAccounts.Concat(new AccountData[] { record }));

        return true;
    }
}

4.5. Enabling the custom accounts provider

After installing the extension, the custom accounts provider needs to be enabled in the configuration settings.

Select the option to show advanced settings and edit the Custom Accounts Provider configuration setting, choosing your accounts provider from the drop-down menu.

Configuring the custom accounts provider
Configuring the custom accounts provider

The provider will immediately take effect and you can go to the Accounts page to view or modify accounts.

5. Debugging

If you want to debug your accounts provider, you can include the following in your code:

System.Diagnostics.Debugger.Launch();

This pops up a window that will allow you to attach the debugger.

Debugging popup
Debugging popup

The following example demonstrates how you would debug the login:

public bool ValidateLocalUserLogOnCredentials(LocalLogOnContext context)
{
    System.Diagnostics.Debugger.Launch();
       ...
    // Rest of the ValidateLocalUserLogOnCredentials method.
}

6. See also

Dundas Data Visualization, Inc.
400-15 Gervais Drive
Toronto, ON, Canada
M3C 1Y8

North America: 1.800.463.1492
International: 1.416.467.5100

Dundas Support Hours:
Phone: 9am-6pm, ET, Mon-Fri
Email: 7am-6pm, ET, Mon-Fri