Customize Standard Authentication Behavior and Supply Additional Logon Parameters (.NET Applications)
- 13 minutes to read
When an XAF application uses the AuthenticationStandard authentication, the default login form displays User Name and Password editors. This topic explains how to customize this form and show Company and Application User lookup editors instead of User Name.
This article applies to .NET applications. For more information on how to implement the same scenario in .NET Framework projects, refer to the following topic: Customize Standard Authentication Behavior and Supply Additional Logon Parameters (.NET Framework Applications).
Define a Data Model for Custom Parameters
Add a
Company
class to your project. This class should contain company names and a list ofApplicationUser
objects as a part of a one-to-many relationship.using DevExpress.Persistent.Base; using DevExpress.Persistent.BaseImpl.EF; using EFCoreCustomLogonAll.Module.BusinessObjects; using System.Collections.ObjectModel; namespace EFCoreCustomLogonAll.Module.BusinessObjects; [DefaultClassOptions] public class Company : BaseObject { public virtual string Name { get; set; } public virtual IList<ApplicationUser> ApplicationUsers { get; set; } = new ObservableCollection<ApplicationUser>(); }
Add the second part of this relationship to the
ApplicationUser
class generated by the Solution Wizard.public class ApplicationUser : PermissionPolicyUser, ISecurityUserWithLoginInfo { // ... public virtual Company Company { get; set; }
Add the
Company
class to your application’sDbContext
(EF Core only).public class EFCoreCustomLogonAllEFCoreDbContext : DbContext { // ... public DbSet<Company> Companies { get; set; }
Add Custom Parameters to the Login Window
The default login window/form displays an AuthenticationStandardLogonParameters
Detail View. The corresponding object includes UserName
and Password
string properties. To change this behavior and add parameters created in the previous step to the login window, add a CustomLogonParameters
class with custom logon parameters to your application’s module project (MySolution.Module). The implementation of this class may require additional adjustments based on the target UI platforms. See the Platform Specifics subsection for more information.
using DevExpress.ExpressApp.Core;
using DevExpress.ExpressApp.DC;
using DevExpress.ExpressApp.Security;
using DevExpress.Persistent.Base;
using MySolution.Module.BusinessObjects;
using Microsoft.Extensions.DependencyInjection;
using System.ComponentModel;
using System.Text.Json.Serialization;
[DomainComponent]
[DisplayName("Log In")]
public class CustomLogonParameters : IAuthenticationStandardLogonParameters, ISupportClearPassword, INotifyPropertyChanged, IDisposable, IServiceProviderConsumer {
private Company company;
private ApplicationUser applicationUser;
private string password;
IServiceProvider? serviceProvider;
readonly List<IDisposable> objToDispose = new List<IDisposable>();
IReadOnlyList<Company>? _companies = null;
[JsonIgnore]
[ImmediatePostData]
[DataSourceProperty("Companies", DataSourcePropertyIsNullMode.SelectAll)]
public Company Company {
get { return company; }
set {
if(value == company)
return;
company = value;
if(ApplicationUser?.Company != company) {
ApplicationUser = null;
}
OnPropertyChanged(nameof(Company));
}
}
[Browsable(false)]
[JsonIgnore]
public IReadOnlyList<Company>? Companies {
get {
if(_companies == null) {
_companies = LoadData();
}
return _companies;
}
}
private IReadOnlyList<Company> LoadData() {
List<Company> companies = new List<Company>();
INonSecuredObjectSpaceFactory nonSecuredObjectSpaceFactory = serviceProvider!.GetRequiredService<INonSecuredObjectSpaceFactory>();
var os = nonSecuredObjectSpaceFactory.CreateNonSecuredObjectSpace<Company>();
objToDispose.Add(os);
companies.AddRange(os.GetObjects<Company>());
return companies.AsReadOnly();
}
void IDisposable.Dispose() {
foreach(IDisposable disposable in objToDispose) {
disposable.Dispose();
}
serviceProvider = null;
}
[JsonIgnore]
[DataSourceProperty("Company.ApplicationUsers"), ImmediatePostData]
public ApplicationUser ApplicationUser {
get { return applicationUser; }
set {
if(value == applicationUser)
return;
applicationUser = value;
Company = applicationUser?.Company;
UserName = applicationUser?.UserName;
OnPropertyChanged(nameof(ApplicationUser));
}
}
[Browsable(false)]
public string UserName { get; set; }
[PasswordPropertyText(true)]
public string Password {
get { return password; }
set {
if(password == value)
return;
password = value;
}
}
private void OnPropertyChanged(string propertyName) {
if(PropertyChanged != null) {
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
public event PropertyChangedEventHandler PropertyChanged;
void IServiceProviderConsumer.SetServiceProvider(IServiceProvider serviceProvider) {
this.serviceProvider = serviceProvider;
}
public void ClearPassword() {
password = null;
}
}
Important notes regarding the code snippet above:
- In the login window/form, users can only edit visible properties. In this example, these are
ApplicationUser
,Company
, andPassword
. - The DataSourcePropertyAttribute specifies contents of the Lookup Property Editor’s drop-down list.
- To show only
ApplicationUser
objects related to the selected company, theCustomLogonParameters
class filters the drop-down’s collection and refreshes it each time a user changes theCompany
property.
- The
CustomLogonParameters
class must implement theISupportClearPassword
andAuthenticationStandardLogonParameters
interfaces.
Platform Specifics
Depending on the target platform, you may need to adjust the CustomLogonParameters
class implementation as described below.
ASP.NET Core Blazor
In Blazor, a logon parameters object is serialized to JSON and stored in a cookie. This has the following implications:
The
CustomLogonParameters
object must support theISupportClearPassword
interface, because the password property must never be added to the cookie.All
CustomLogonParameters
object properties whose data type does not support JSON serialization must be decorated with the JsonIgnoreAttribute.You should not use the
JsonIgnoreAttribute
on serializable properties that need to be submitted to the Web API Service‘sAuthenticate
endpoint. Otherwise, the values of these properties will be impossible to submit.To keep the cookie compact, we recommend that you assign
null
to all properties that are not required by the application after the logon process is complete. You can do this in theISupportClearPassword.ClearPassword
method.[DomainComponent] [DisplayName("Log In")] public class CustomLogonParameters : IAuthenticationStandardLogonParameters, ISupportClearPassword, INotifyPropertyChanged, IDisposable, IServiceProviderConsumer { // ... public void ClearPassword() { password = null; CustomData = null; } // ... }
WinForms
The WinForms platform requires CustomLogonParameters
to implement the ISupportClearPassword
and IAuthenticationStandardLogonParameters
interfaces. No additional requirements apply.
WinForms with Middle Tier Security
The
CustomLogonParameters
object’s properties must be decorated with appropriate attributes depending on whether or not these properties are serializable and/or must be serialized and passed between the client and server after the logon process is complete:- DataMemberAttribute - if a property is serializable and must be passed between the client and server.
- IgnoreDataMemberAttribute - if a property is not serializable or should not be passed between the client and server.
If nullable reference types are enabled for the whole project, all properties in the
CustomLogonParameters
class must be marked as nullable. Otherwise, in some cases, faulty behavior can occur during a user login attempt (when a user leaves a logon parameter input empty but tries to log in).
Implement Custom Authentication
Next, you need to implement custom logic that makes authentication decisions against the custom set of logon parameters specified by a user. To do this, use one of the following techniques:
Handle the OnAuthenticate Event
Handle the OnAuthenticate event to implement custom logic required to authenticate a user. Note that this logic completely overrides the default logic used for password-based authentication.
File: MySolution.Blazor.Server\Startup.cs, MySolution.Win\Startup.cs (MySolution.MiddleTier\Startup.cs), MySolution.WebApi\Startup.cs
// ...
builder.Security
.UseIntegratedMode(options => {
// ...
})
.AddPasswordAuthentication(options => {
options.IsSupportChangePassword = true;
options.LogonParametersType = typeof(CustomLogonParameters);
options.Events.OnAuthenticate = context => {
CustomLogonParameters logonParameters = (CustomLogonParameters)context.LogonParameters;
ApplicationUser applicationUser = context.ObjectSpace.FirstOrDefault<ApplicationUser>(e => e.UserName == context.LogonParameters.UserName);
if (applicationUser == null)
throw new AuthenticationException(
context.LogonParameters.UserName,
SecurityExceptionLocalizer.GetExceptionMessage(SecurityExceptionId.RetypeTheInformation)
);
var userLockoutService = context.ObjectSpace.ServiceProvider.GetRequiredService<IUserLockout>();
if (userLockoutService.IsLockedOut(applicationUser)) {
ISecurityUserLockout userLockout = (ISecurityUserLockout)applicationUser;
throw new AuthenticationException(logonParameters.UserName, SecurityExceptionLocalizer.GetExceptionMessage(SecurityExceptionId.UserLockout, Math.Floor((userLockout.LockoutEnd - DateTime.UtcNow).TotalSeconds)));
}
if (applicationUser.Company.ID != logonParameters.Company.ID) {
throw new AuthenticationException(
applicationUser.UserName, "No such user in the specified company.");
}
if (!((IAuthenticationStandardUser)applicationUser).ComparePassword(logonParameters.Password)) {
userLockoutService.AccessFailed(applicationUser, context.ObjectSpace);
throw new AuthenticationException(
applicationUser.UserName, "Password mismatch.");
}
if (!((ISecurityUser)applicationUser).IsActive) {
throw new AuthenticationException(applicationUser.UserName, SecurityExceptionLocalizer.GetExceptionMessage(SecurityExceptionId.UserIsNotActive));
}
userLockoutService.ResetLockout(applicationUser, context.ObjectSpace);
context.User = applicationUser;
};
});
// ...
Implement a Custom Authentication Provider (Advanced)
Add a CustomAuthentication
class to your solution’s module project (MySolution.Module). Note that this class should inherit from the AuthenticationBase class.
using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Security;
using DevExpress.Persistent.Base.Security;
using MySolution.Module.BusinessObjects;
using Microsoft.Extensions.DependencyInjection;
public class CustomAuthentication : AuthenticationBase, IAuthenticationStandard {
private CustomLogonParameters customLogonParameters;
public CustomAuthentication() {
customLogonParameters = new CustomLogonParameters();
}
public override void Logoff() {
base.Logoff();
customLogonParameters = new CustomLogonParameters();
}
public override void ClearSecuredLogonParameters() {
customLogonParameters.Password = "";
base.ClearSecuredLogonParameters();
}
public override object Authenticate(IObjectSpace objectSpace) {
ApplicationUser applicationUser = objectSpace.FirstOrDefault<ApplicationUser>(e => e.UserName == customLogonParameters.UserName);
if(applicationUser == null)
throw new ArgumentNullException("applicationUser");
var userLockoutService = objectSpace.ServiceProvider.GetRequiredService<IUserLockout>();
if(userLockoutService.IsLockedOut(applicationUser) ?? false) {
ISecurityUserLockout userLockout = (ISecurityUserLockout)applicationUser;
throw new AuthenticationException(customLogonParameters.UserName, SecurityExceptionLocalizer.GetExceptionMessage(SecurityExceptionId.UserLockout, Math.Floor((userLockout.LockoutEnd - DateTime.UtcNow).TotalSeconds)));
}
if(!((IAuthenticationStandardUser)applicationUser).ComparePassword(customLogonParameters.Password)) {
userLockoutService.AccessFailed(applicationUser, objectSpace);
throw new AuthenticationException(
applicationUser.UserName, "Password mismatch.");
}
if(!((ISecurityUser)user).IsActive) {
throw new AuthenticationException(user.UserName, SecurityExceptionLocalizer.GetExceptionMessage(SecurityExceptionId.UserIsNotActive));
}
if(!ValidateSecurityUser(user)) {
throw new AuthenticationException(user.UserName);
}
userLockoutService.ResetLockout(user, objectSpace);
return applicationUser;
}
public override void SetLogonParameters(object logonParameters) {
customLogonParameters = (CustomLogonParameters)logonParameters;
}
public override IList<Type> GetBusinessClasses() {
return new Type[] { typeof(CustomLogonParameters) };
}
public override bool AskLogonParametersViaUI {
get { return true; }
}
public override object LogonParameters {
get { return customLogonParameters; }
}
public override bool IsLogoffEnabled {
get { return true; }
}
}
For information on methods and properties that are overridden in this code snippet, refer to the AuthenticationBase class description.
Add a CustomAuthenticationStandardProvider
class to your module project (MySolution.Module). Ensure that this class inherits from the AuthenticationStandardProviderV2
class. Override the CreateAuthentication
method of the newly created class to return a CustomAuthentication
instance.
using DevExpress.ExpressApp.Security;
using Microsoft.Extensions.Options;
namespace EFCustomLogon.Module.BusinessObjects;
public class CustomAuthenticationStandardProvider : AuthenticationStandardProviderV2 {
public CustomAuthenticationStandardProvider(IOptions<AuthenticationStandardProviderOptions> options,
IOptions<SecurityOptions> securityOptions) :
base(options, securityOptions) { }
protected override AuthenticationBase CreateAuthentication(Type userType, Type logonParametersType) {
return new CustomAuthentication();
}
}
Pass Custom Classes to the Security System
If You Used the OnAuthenticate Event
In the application’s Startup.cs files, set the provider’s LogonParametersType
option to CustomLogonParameters
within the AddPasswordAuthentication
method call.
File: MySolution.Blazor.Server\Startup.cs, MySolution.Win\Startup.cs, (MySolution.MiddleTier\Startup.cs), MySolution.WebApi\Startup.cs
// ...
builder.Security
.UseIntegratedMode(options => {
// ...
})
.AddPasswordAuthentication(options => {
options.IsSupportChangePassword = true;
options.LogonParametersType = typeof(CustomLogonParameters);
});
// ...
If You Implemented a Custom Authentication Provider
In the application’s Startup.cs files:
- Comment out the
AddPasswordAuthentication
method. - Pass a custom
CustomAuthenticationStandardProvider
toSecurity
. - Set the provider’s
LogonParametersType
option toCustomLogonParameters
.
File: MySolution.Blazor.Server\Startup.cs, MySolution.Win\Startup.cs, (MySolution.MiddleTier\Startup.cs), MySolution.WebApi\Startup.cs
// ...
builder.Security
.UseIntegratedMode(options => {
// ...
})
.AddAuthenticationProvider<AuthenticationStandardProviderOptions, CustomAuthenticationStandardProvider>(options => {
options.IsSupportChangePassword = true;
options.LogonParametersType = typeof(CustomLogonParameters);
});
//.AddPasswordAuthentication(options => {
// options.IsSupportChangePassword = true;
//});
// ...
Add Demo Data
Override the ModuleUpdater.UpdateDatabaseAfterUpdateSchema method to create companies, application users, and security roles.
using DevExpress.ExpressApp.Security;
using DevExpress.ExpressApp.SystemModule;
// ...
public class Updater : ModuleUpdater {
public Updater(IObjectSpace objectSpace, Version currentDBVersion) :
base(objectSpace, currentDBVersion) {
}
public override void UpdateDatabaseAfterUpdateSchema() {
base.UpdateDatabaseAfterUpdateSchema();
//string name = "MyName";
//EntityObject1 theObject = ObjectSpace.FirstOrDefault<EntityObject1>(u => u.Name == name);
//if(theObject == null) {
// theObject = ObjectSpace.CreateObject<EntityObject1>();
// theObject.Name = name;
//}
#if !RELEASE
var defaultRole = CreateDefaultRole();
var adminRole = CreateAdminRole();
ObjectSpace.CommitChanges(); //This line persists created object(s).
UserManager userManager = ObjectSpace.ServiceProvider.GetRequiredService<UserManager>();
ApplicationUser userAdmin = userManager.FindUserByName<ApplicationUser>(ObjectSpace, "Admin");
// If a user named 'Admin' doesn't exist in the database, create this user.
if (userAdmin == null) {
// Set a password if the standard authentication type is used.
string EmptyPassword = "";
userAdmin = userManager.CreateUser<ApplicationUser>(ObjectSpace, "Admin", EmptyPassword, (user) => {
// Add the Administrators role to the user.
user.Roles.Add(adminRole);
}).User;
}
if (ObjectSpace.FindObject<Company>(null) == null) {
Company company1 = ObjectSpace.CreateObject<Company>();
company1.Name = "Company 1";
company1.ApplicationUsers.Add(userAdmin);
ApplicationUser user1 = userManager.CreateUser<ApplicationUser>(ObjectSpace, "Sam", "", (user) => {
user.Roles.Add(defaultRole);
}).User;
ApplicationUser user2 = userManager.CreateUser<ApplicationUser>(ObjectSpace, "John", "", (user) => {
user.Roles.Add(defaultRole);
}).User;
Company company2 = ObjectSpace.CreateObject<Company>();
company2.Name = "Company 2";
company2.ApplicationUsers.Add(user1);
company2.ApplicationUsers.Add(user2);
}
ObjectSpace.CommitChanges(); //This line persists created object(s).
#endif
// ...
}
}
Generate a Demo Database
Standard XAF applications generate data after a user logs in. To generate a database and demo data before login, use one of the following techniques depending on the target platform:
Blazor, WinForms with Middle-Tier Security
Add an
ApplicationBuilderExtensions
extension class to your Blazor or Middle-Tier Server project:using DevExpress.ExpressApp.Core; namespace MySolution.Blazor.Server.Security; static class ApplicationBuilderExtensions { // ... //} public static IApplicationBuilder UseDemoData(this IApplicationBuilder app) { using var scope = app.ApplicationServices.CreateScope(); var updatingObjectSpaceFactory = scope.ServiceProvider.GetRequiredService<IUpdatingObjectSpaceFactory>(); using var objectSpace = updatingObjectSpaceFactory.CreateUpdatingObjectSpace(typeof(Module.BusinessObjects.ApplicationUser), true); new Module.DatabaseUpdate.Updater(objectSpace, new Version()).UpdateDatabaseAfterUpdateSchema(); return app; } }
Utilize the newly created class in the Startup.cs file to generate data:
File: MySolution.Blazor/Startup.cs, MySolution.MiddleTier/Startup.cs
public class Startup { // ... public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { // ... app.UseDemoData(); } }
WinForms
Add the following line to the WinForms project’s Program.cs file:
File: MySolution.Win/Program.cs
public static int Main(string[] args) {
// ...
try {
winApplication.Setup();
# if DEBUG
winApplication.CheckCompatibility();
# endif
winApplication.Start();
}
// ...
}
Platform-Specific Steps
You also need to complete the steps described below to configure some of your solution’s platform-specific application modules to work with custom logon parameters.
Web API Service (Standalone)
A Web API Service application exposes an Authenticate
endpoint. For this endpoint, the logon parameters type is explicitly specified and used by the deserialization logic. For authentication to work correctly, you need to replace the default type with your custom type in the AuthenticationController
implementation. You can also update the SwaggerRequestBody
attribute for the Swagger UI to display a proper hint on the request body format:
File: MySolution.WebApi/API/Security/AuthenticationController.cs
public class AuthenticationController : ControllerBase {
// ...
public IActionResult Authenticate(
[FromBody]
[SwaggerRequestBody(@"For example: <br /> { ""userName"": ""Admin"", ""password"": """", ""customParameter:"" ""customParameterValue"" }")]
// AuthenticationStandardLogonParameters logonParameters
CustomLogonParameters logonParameters
) {
// ...
}
}
WinForms with Middle-Tier Security
Register the Custom Logon Parameters Class in the Client Application
The CustomLogonParameters
class must be explicitly registered in the client WinForms application. To do this, add the following line to the Main method within the client application’s Program.cs file:
File: MySolution.Win/Program.cs
static class Program {
// ...
[STAThread]
public static int Main(string[] args) {
// ...
DevExpress.ExpressApp.Security.ClientServer.WebApi.WebApiDataServerHelper.AddKnownType(typeof(CustomLogonParameters));
// ...
}
// ...
}
If this step is omitted, an exception will occur when you try to run the client application.
Register Types Allowed for Anonymous Access
This step is required if your CustomLogonParameters
implementation needs to access some objects to populate the login window’s editors. For example, consider the following method from the CustomLogonParameters
implementation shown in this topic:
private IReadOnlyList<Company> LoadData() {
List<Company> companies = new List<Company>();
INonSecuredObjectSpaceFactory nonSecuredObjectSpaceFactory = serviceProvider!.GetRequiredService<INonSecuredObjectSpaceFactory>();
var os = nonSecuredObjectSpaceFactory.CreateNonSecuredObjectSpace<Company>();
objToDispose.Add(os);
companies.AddRange(os.GetObjects<Company>());
return companies.AsReadOnly();
}
You can see that this code uses a Non-Secure Object Space Factory to read objects of the Company
type. However, in an application with Middle Tier Security, data access operations are validated by the security system if initiated on the client side even when a Non-Secure Object Space is used. Because no user is logged in at the stage when this code runs, trying to access data through the Object Space will result in an exception.
For this code to work, you need to explicitly allow anonymous access to objects of the Company
type. To do this, add the following line to the OnSecurityStrategyCreated
event handler in the server application’s Startup.cs file:
File: MySolution.MiddleTier/Startup.cs
// ...
builder.Security
.UseIntegratedMode(options => {
// ...
options.Events.OnSecurityStrategyCreated += securityStrategy => {
// ...
((SecurityStrategy)securityStrategy).AnonymousAllowedTypes.Add(typeof(Company));
};
// ...
});
Run the Application
You can now run the application to see custom parameters (Company and Application User lookup editors) in the login window.