Cofoundry 0.10 is a substantial update focused on features relating to users and user areas. If you're not familiar with user areas, it is a feature that makes it easy to create sections of your application for one or more sets of users, such as a member or customer area. The improvements in this release affects any custom user areas you've defined as well as the Cofoundry admin panel user area, which shares the same base framework.

Version 0.10 includes more than 50 new features and documentation updates, as well as some reworking of existing features. This blog post will cover the main themes but if you're already working with user areas it will be worth reading the new user areas documentation and running through the full release notes.

Updated documentation and new samples

We now have a dedicated section in the docs to cover user areas in more detail, as well as two new sample projects:

  • Cofoundry.Samples.UserAreas: Includes examples of registration, authentication, account recovery, account verification, account management and more
  • Cofoundry.Samples.Mail: Includes examples of custom mail templates, sending mail and customizing email notifications for admin panel users

User Areas

Configuration

A fundamental part of our work on user areas was looking at definition and configuration. The requirements for a user area can vary wildly and so we've opted for a layered configuration approach, building on the existing definition files to provide easy access to setting customization.

Firstly, configuration can be set globally in your app.config file:

{
  "Cofoundry": {
    "Users": {
      "Password": {
        "MinLength": 12,
        "MinUniqueCharacters": 5
      },
      "AccountRecovery": {
        "ExpireAfter": "01:00:00"
      },
      "Username": {
        "AllowAnyDigit": false,
        "AdditionalAllowedCharacters": "-."
      }
    }
  }
}

Global settings apply to all user areas including the Cofoundry admin user area; to configure a specific user area you can use the new ConfigureOptions(UserAreaOptions) method on your definition class:

using Cofoundry.Domain;

public class MemberUserArea : IUserAreaDefinition
{
    public const string Code = "MEM";

    public string UserAreaCode => Code;

    public string Name => "Member";

    // other properties removed for brevity

    public void ConfigureOptions(UserAreaOptions options)
    {
        options.Password.MinUniqueCharacters = 6;
    }
}

For even finer grained control, many configuration features allow you to define a class to fully customize a feature. Here's an example of how we can define a completely custom password policy for a member user area:

public class MemberPasswordPolicyConfiguration : IPasswordPolicyConfiguration<MemberUserArea>
{
    public void Configure(IPasswordPolicyBuilder builder)
    {
        builder
            .SetDescription($"Passwords must be between 12 and 300 characters.")
            .ValidateMinLength(12)
            .ValidateMaxLength(300)
            .ValidateMinUniqueCharacters(5)
            .ValidateNotCurrentPassword()
            .ValidateNotPersonalData()
            .ValidateNotSequential()
            // or add your own custom validator
            ;
    }
}

New configurable features include:

  • Password policies
  • Username validation
  • Email address validation
  • Additional security settings
  • Log retention

More detailed information on configuration can be found in the new user area configuration section in the docs.

Mail Templates

The default mail templates for a user area are simple and unbranded, however if you're developing a custom user area it is likely that you'll want to customize these. This can now be done by implementing IUserMailTemplateBuilder<TUserAreaDefinition>, which makes it easy to customize the default templates or create own:

public class MemberMailTemplateBuilder : IUserMailTemplateBuilder<MemberUserArea>
{
    public async Task<IMailTemplate> BuildNewUserWithTemporaryPasswordTemplateAsync(INewUserWithTemporaryPasswordTemplateBuilderContext context)
    {
        // customize the subject on the default template
        var template = await context.BuildDefaultTemplateAsync();
        template.SubjectFormat = "A new account has been created for you!";

        return template;
    }

    public async Task<IMailTemplate> BuildPasswordChangedTemplateAsync(IPasswordChangedTemplateBuilderContext context)
    {
        // ignore the default and build a custom template
        var template = new ExamplePasswordChangedMailTemplate()
        {
            Username = context.User.Username
        };

        return Task.FromResult<IMailTemplate>(template);
    }

    public async Task<IMailTemplate> BuildPasswordResetTemplateAsync(IPasswordResetTemplateBuilderContext context)
    {
        // customise the layout on the default template
        var template = await context.BuildDefaultTemplateAsync();
        template.LayoutFile = "~/Cofoundry/MailTemplates/Members/Layouts/_MailLayout";

        return template;
    }

    public async Task<IMailTemplate> BuildAccountRecoveryTemplateAsync(IAccountRecoveryTemplateBuilderContext context)
    {
        // customize the view file on the default template
        var template = await context.BuildDefaultTemplateAsync();
        template.ViewFile = "~/Cofoundry/MailTemplates/Members/AccountRecoveryMailTemplate";

        return template;
    }

    public async Task<IMailTemplate> BuildAccountVerificationTemplateAsync(IAccountVerificationTemplateBuilderContext context)
    {
        // return the default template unmodified
        return await context.BuildDefaultTemplateAsync();
    }

The same approach applies to customizing the email notifications for Cofoundry admin panel users, all you need to do is implement IUserMailTemplateBuilder<CofoundryAdminUserArea>.

More detailed information on configuration can be found in the new email notifications configuration section in the docs.

Content API Improvements

In this release our aim has been to bring together all the elements of working with local user accounts into a powerful, flexible, secure and easy to use system for creating bespoke user areas. To achieve this we have added a comprehensive set of user APIs inside our front-end agnostic content repositories. Given that many of these features are for advanced scenarios, most of these APIs can be found in IAdvancedContentRepository.

This example illustrates using IAdvancedContentRepository to authenticate and sign in with one single action:

await _advancedContentRepository
    .Users()
    .Authentication()
    .SignInWithCredentialsAsync(new SignInUserWithCredentialsCommand()
    {
        UserAreaCode = MemberUserArea.Code,
        Username = "ExampleUser",
        Password = "ExamplePassword"
    });

Integration with ModelState

To make it easier to work with these APIs in MVC controllers, API controllers or Razor Pages, we've introduced a new fluent extension to our repositories that can interact with model state. Invoking UseModelState in the call chain will:

  • Prevent execution if the model state is invalid
  • Capture validation exceptions and push them into model state
  • Push errors in query results derived from ValidationQueryResult into model state

Here is an example of initiating an account recovery request from a Razor Page:

using Cofoundry.Domain;
using Cofoundry.Web;

public class ForgotPasswordModel : PageModel
{
    private readonly IAdvancedContentRepository _advancedContentRepository;

    public ForgotPasswordModel(
        IAdvancedContentRepository advancedContentRepository
        )
    {
        _advancedContentRepository = advancedContentRepository;
    }

    [BindProperty]
    [Required]
    public string Username { get; set; }

    public bool IsSuccess { get; set; }

    public async Task<IActionResult> OnGetAsync()
    {
        return Page();
    }

    public async Task OnPostAsync()
    {
        await _advancedContentRepository
            .WithModelState(this)
            .Users()
            .AccountRecovery()
            .InitiateAsync(new InitiateUserAccountRecoveryViaEmailCommand()
            {
                UserAreaCode = MemberUserArea.Code,
                Username = Username
            });

        IsSuccess = ModelState.IsValid;
    }
}

Combining APIs to make custom flows

Armed with these flexible APIs you can compose bespoke flows to match your requirements. This example is taken from the user area sample project and combines user registration with the new account verification feature inside of an MVC controller action:

using Cofoundry.Domain;
using Cofoundry.Web;

[HttpPost("register")]
public async Task<IActionResult> Register(RegisterViewModel viewModel)
{
    // When executing multiple commands we should run them inside a transaction
    using (var scope = _advancedContentRepository.Transactions().CreateScope())
    {
        // The anonymous user does not have permission to add users
        // so we need to run the commands under the system account by
        // calling "WithElevatedPermissions()"
        var userId = await _advancedContentRepository
            .WithElevatedPermissions()
            .WithModelState(this)
            .Users()
            .AddAsync(new AddUserCommand()
            {
                UserAreaCode = MemberUserArea.Code,
                RoleCode = MemberRole.Code,
                Username = viewModel.Username,
                Password = viewModel.Password,
                Email = viewModel.Email
            });

        // In this example we require members to validate their account 
        // before we let them sign in. Initiating verification will send an
        // email notification containing a unique link to verify the account
        await _advancedContentRepository
            .WithModelState(this)
            .Users()
            .AccountVerification()
            .EmailFlow()
            .InitiateAsync(new InitiateUserAccountVerificationViaEmailCommand()
            {
                UserId = userId
            });

        // This helper method will only complete the transaction if 
        // the model state is valid
        await scope.CompleteIfValidAsync(ModelState);
    }

    if (!ModelState.IsValid)
    {
        return View(viewModel);
    }

    return View("RegistrationSuccess", viewModel);
}

If you want to push this logic down a layer using either our CQS framework or your own structure then of course you can still do that too, just request the IAdvancedContentRepository from the DI container.

Partial updates

Another new feature we've added is partial update or "patch" overloads to content API commands that update data. These overloads allow you to modify a command populated with existing data via a delegate action, so you don't need to specify every property on the command.

In the following example we use this feature to update only the "DisplayName" property on the currently signed in user. This example implements the logic in a CQS handler:

using Cofoundry.Domain;

public class UpdateMemberDisplayNameCommandHandler
    : ICommandHandler<UpdateMemberDisplayNameCommand>
    , IIgnorePermissionCheckHandler
{
    private readonly IAdvancedContentRepository _advancedContentRepository;

    public UpdateMemberDisplayNameCommandHandler(
        IAdvancedContentRepository advancedContentRepository
        )
    {
        _advancedContentRepository = advancedContentRepository;
    }

    public async Task ExecuteAsync(UpdateMemberDisplayNameCommand command, IExecutionContext executionContext)
    {
        await _advancedContentRepository
            .Users()
            .Current()
            .UpdateAsync(u => u.DisplayName = command.DisplayName);
    }
}

Pages, Directories and Access Control

Once you have a user area, you'll probably want to secure some of your routes or CMS pages. We've added some additional authorization attributes for controlling access to routes, but one big new feature in this release is access control for CMS pages, which can now be configured in the admin panel by clicking on the Access Control button on an individual page or directory screen:

the new page access rules screen in the Cofoundry admin panel

You can read more about this in the access control documentation.

Improved role initialization

When defining a code-based role you now configure the default permissions associated with the role in the definition itself, rather than having to define a separate role initializer. In addition, permissions are now configured using a more intuitive builder pattern, allowing you to express permission selection in both an opt-in and opt-out style. The builder includes a range of discoverable extensions for various categories of permissions and also provides the ability to copy permission configuration from other roles.

This example uses the anonymous role as a base, and adds in a some extra permissions:

public class MarketingRole : IRoleDefinition
{
    // ...other properties omitted for brevity

    public void ConfigurePermissions(IPermissionSetBuilder builder)
    {
        builder
            .ApplyAnonymousRoleConfiguration()
            .IncludeCurrentUser(currentUserPermissions => currentUserPermissions.Update())
            .Include<ExamplePermission>();
    }
}

Take a look at the updated roles documentation for more information.

Security

This release includes a number of improvements to user security including:

  • Highly configurable password policies, see the password policy configuration documentation for more details.
  • Session invalidation when security data is updated, managed via security stamps. See issue #478 for details.
  • Password hashing is now deferred to the ASP.NET Core Identity password hasher. Additional documentation has been added on how to configure or upgrade the password hasher.
  • Duration padding has been added to queries and commands that are susceptible to time-based enumeration attacks such as authentication and account recovery. See issue #491 for more details.
  • The existing rate limiting feature for authentication and account recovery features have been integrated into the queries and commands for these features to ensure they cannot be accidently bypassed. The configuration has also been standardized and moved into the new user areas configuration scheme. See #492 for more information.
  • Admin users can now issue a hard password reset of a users password from the admin panel. See issue #480 for more details.

User deletion and deactivation

  • User soft-deletes now clears or anonymizes any data fields. See issue #494 for more details.
  • There is a new API to allow the current user to delete their account and sign them out, see account management docs for details
  • Users can now be put into a deactivated state, which keeps their data but prevents them from signing in. See issue #490 for details.

User data changes

  • The user first and last name are no longer required. See issue #467 for details.
  • A new "DisplayName" field has been added to a user to provide a more flexible name field that can be used for a range of purposes. See issue #496 for details.
  • The UserMicroSummary projection no longer contains the FirstName, LastName or Email fields and instead contains the DisplayName field. See issue #495 for details.

Other Features

Custom descriptions for enum-based options

List-style data annotations that use an enum option source now support the [Description(string)] data annotation on the enum values. Prior to v0.10 the enum names would be "humanized" to add spaces between words, but the description attribute allows an additional level of customization:

public enum ShrubOption
{
    [Description("Buddleja")]
    BuddlejaDavidii,
    
    [Description("Spindleberry")]
    EuonymusEuropaeus,
    
    [Description("Cornelian Cherry")]
    CornusMas
}

public class ExampleDataModel : ICustomEntityDataModel
{
    [RadioList(typeof(ShrubOption))]
    public ShrubOption FavoriteShrub { get; set; }
}

The above example will render with the text in the description attributes:

Example of how description annotations render for enum option sources

Improvements to file sources

File sources are used when adding or updating files such as images and documents. Previously these were represented by the IUploadedFile interface, but this has been renamed IFileSource and implementations have been adjusted to improve consistency and make them easier to use.

This release also includes a new EmbeddedResourceFileSource which is useful for testing or for initializing assets bundled into an assembly:

public async Task AddImage()
{
    var fileSource = new EmbeddedResourceFileSource(this.GetType().Assembly, "MyProject.MyNamespace.MyFolder", "myimage.jpg");
    var imageAssetId = await _advancedContentRepository
        .ImageAssets()
        .AddAsync(new AddImageAssetCommand()
        {
            Title = "My Embedded Image",
            File = fileSource
        });
}

You can find more details in the new file source documentation.

Breaking changes

This release includes a large number of breaking changes, but they are mostly related to user areas, which isn't expected to be widely used as it wasn't particularly well documented prior to this release. If you are struggling with the update, please post an issue on GitHub and we'll assist.

Here are some of the key changes:

  • Code previously marked as obsolete has been removed, see issue #481 for more details.
  • IControllerResponseHelper and the auth controller helpers have been removed and replaced with the new content API features, see issue #489
  • The configuration settings in AuthenticationSettings have been moved to UserSettings.Authentication. If you do have any of the old settings configured, an exception will be thrown. See issue #493 for the new configuration mappings.
  • Term "PasswordReset" (self-service forgot-password style) changed to "AccountRecovery" to avoid confusion with passwords being reset by an administration user, see issue #479
  • Term "Login/Logged In" etc standardized as "Sign in/signed in", see issue #487

A longer list of breaking changes can be found in the v0.10 release notes.

.NET 6

We've had a few requests regarding support for .NET 6, which is the current LTS version of .NET Core. There's been a lot of work to do on v0.10, so unfortunately we couldn't include it in this release, but migrating Cofoundry to .NET 6 will be the primary focus of our next major version.

Links

For a complete list of bug fixes and features, check out the full release notes on GitHub using the link below.